diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a945ff9..6319416 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "papervizagent", - "version": "0.4.0", + "version": "0.5.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "author": { "name": "GoatInAHat" @@ -57,7 +57,7 @@ "command": "uvx", "args": [ "--from", - "papervizagent==0.4.0", + "papervizagent==0.5.0", "papervizagent", "mcp" ], diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 21c3ecf..bc85ad7 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "papervizagent", - "version": "0.4.0", + "version": "0.5.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "author": { "name": "GoatInAHat" diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7007f2a..2674de5 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "papervizagent", - "version": "0.4.0", + "version": "0.5.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "author": { "name": "GoatInAHat" diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 2ce5fb5..33b1faf 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -10,13 +10,7 @@ skill and managed Codex sign-in in the standalone runtime. No model IDs are hardcoded into automatic selection. ```yaml -models: - llm: {provider: codex} - vlm: {provider: codex} - image: {provider: codex} -roles: - critic: - options: {effort: high} +model_policy: balanced pipeline: task_name: diagram exp_mode: demo_full @@ -24,28 +18,61 @@ pipeline: max_critic_rounds: 3 num_candidates: 1 max_concurrent: 4 -codex: - access_token_env: CODEX_OAUTH_TOKEN - account_id_env: CODEX_ACCOUNT_ID - timeout: 600 ``` -This example explicitly assigns all modalities to Codex. To keep the host's -tools as defaults, omit `models` and configure only the roles you want to -override. `status` accepts the native capabilities the host has verified; -`models` reads the actual subscription catalog, including vision eligibility. -Copy an available model ID into any modality or role's `model` field to select -it. An explicit override that fails is an error; it does not silently switch -providers. A role's fields merge over its modality's fields. Native role options are -limited by the host's own controls; `status` exposes common scalar generation -controls and reports other keys as unavailable, without exposing SDK options -or credentials. Use a configured provider for its full SDK option set. +This example preserves verified host-native tools, with managed Codex sign-in +for missing modalities. `status` accepts the native capabilities the host has +verified. `models` reads the actual subscription catalog and reports automatic +choices and selection reasons without making an inference request. + +`model_policy` is `balanced` by default; choose `quality` for highest-quality +work. The `status`, `infer`, and `generate` operations also accept a per-request +`model_policy` override. Native skill roles use a supported current mid-tier +alias or live catalog; Claude Code uses `sonnet`, or `opus` for quality. Hosts +without tier information use their own default and record the fallback. + +The Codex runtime discovers models at connection time. Automatic selection +excludes hidden and specialized entries, requires vision support for image +inputs, and prefers entries without retirement/upgrade hints. It uses the +provider's balanced/mid-tier or flagship/most-capable descriptions, then the +provider default, then catalog order. These advisory labels cannot guarantee +lowest cost or benchmark ranking. Model IDs and version numbers are never +hardcoded or guessed. Selection reasons are recorded in traces, so a changed +catalog remains inspectable. Balanced effort is medium for text/vision and low +for the image-tool coordinator where supported; quality prefers high effort. + +Copy an available model ID into a modality or role's `model` field to override +automatic selection. Set `provider` to explicitly override native-first routing. +For example, `models.image: {provider: codex, model: YOUR_AVAILABLE_MODEL}` pins +the Codex coordinator while leaving native text and vision available. An +explicit override that fails is an error; it does not silently switch providers +or models. A role's fields merge over its modality's fields. Explicit effort +options also win over the policy defaults. + +Native role options are limited by the host's own controls; `status` exposes +common scalar generation controls and reports other keys as unavailable, +without exposing SDK options or credentials. Use a configured provider for its +full SDK option set. Supported roles: `retriever`, `planner`, `stylist`, `visualizer`, `critic`, `vanilla`, `polish`. Text-producing roles use `vlm` when their actual inputs contain images, otherwise `llm`. Render calls use `image`. This includes the two different modalities used by polish. +## Native Python callbacks in 0.5.0 + +Version 0.5.0 extends the embedded `Backend(native={...})` callback contract. +Callbacks with an explicit signature must add the keyword arguments `model` +and `model_policy` alongside `role`, `modality`, `system`, `contents`, and +`options`. `model` is `None` when unconfigured; otherwise apply that exact +model. When it is absent, resolve `model_policy` through the host's supported +aliases/catalog. Preserve explicit controls in `options`. Returning a result +without applying an explicit model is not a valid native adapter. + +This is a breaking change for callbacks with the old five-argument signature; +update the adapter before upgrading. Skills and generated CLI/MCP/host adapters +already share the updated policy and do not need callback code. + ## Providers Each model/role entry accepts `provider`, `model`, `api_key_env`, `api_key`, @@ -86,7 +113,10 @@ same Gemini path and `gpt-image` image path as upstream. Portable `models`, take precedence. Select Anthropic explicitly to use its text adapter. Codex image `model` selects the coordinator, not the service-managed image -generator. Image size/aspect/quality/background are requested in the prompt, +generator. The subscription interface does not expose or report its underlying +image-model ID, so it cannot guarantee a `gpt-image-2` pin. Existing Codex login +is reused through the official SDK without copying OAuth credentials. +Image size/aspect/quality/background are requested in the prompt, not guaranteed API dimensions. Its upstream temperature/token/count defaults are recorded as unavailable in traces. Explicit unsupported Codex options fail. Select Gemini or OpenAI image models for their direct API controls. @@ -96,6 +126,33 @@ polish selects editing automatically. Ordinary Visualizer calls are fresh generation from the revised text description. Gemini and Codex can condition on reference image inputs through their native multimodal interfaces. +## Claude Code with existing Codex sign-in + +Install the shared skill and an MCP server. While PyPI publication is pending, +use Git source for the runtime too; the generated marketplace plugin's PyPI +command requires that publication. These commands install the current main +branch; replace `main` with a reviewed commit for a reproducible installation. + +```sh +npx skills add GoatInAHat/PaperVizAgent --skill papervizagent --agent claude-code --global --yes +claude mcp add --scope user papervizagent -- uvx --from git+https://github.com/GoatInAHat/PaperVizAgent@main papervizagent mcp +claude --model sonnet +``` + +Claude and Codex each use their own existing login (`claude auth login` and +`codex login` if needed). Do not move Codex OAuth tokens into Claude. Ask Claude +to use the PaperVizAgent skill: fresh Claude roles plan and inspect images; +`infer` invokes the Codex image bridge only when that modality is absent. +The Codex image bridge necessarily includes a small coordinating LLM request, +with low effort under the balanced policy; it is not a direct image-only API. + +For an unattended demonstration, constrain built-in tools with `--tools` and +use `--strict-mcp-config` with an explicit server file. Allow Read, Write, Agent +and the needed MCP operations; omit scheduler/polling tools. Resume on Claude's +automatic role-completion notifications. Record the resolved model metadata, +actual artifact inspection and any failed stages rather than assuming a host +integration implies success. + ## External Codex tokens Supply `CODEX_OAUTH_TOKEN` through the host's secret field or environment. An diff --git a/README.md b/README.md index b8ac830..f45bdeb 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Scientific figures using [Google Research’s official PaperVizAgent](https://gi The Python processor, seven agent implementations and complete style guides are reused from pinned upstream source. [ToolFactory](https://github.com/GoatInAHat/toolfactory) generates the integrations and release packages from that one runtime. -**Release status:** GitHub/source installation is available; the PyPI, npm, ClawHub and browser-store commands below require the publisher setup described in [RELEASING.md](RELEASING.md). Until PyPI is published, run the runtime from the release source: +**Release status:** GitHub/source installation is available; the PyPI, npm, ClawHub and browser-store commands below require the publisher setup described in [RELEASING.md](RELEASING.md). Until PyPI is published, run the runtime from source: ```sh -uvx --from git+https://github.com/GoatInAHat/PaperVizAgent@v0.4.0 papervizagent mcp +uvx --from git+https://github.com/GoatInAHat/PaperVizAgent@main papervizagent mcp ``` @@ -23,18 +23,18 @@ uvx --from git+https://github.com/GoatInAHat/PaperVizAgent@v0.4.0 papervizagent - **Gemini CLI extension** — `gemini extensions install https://github.com/GoatInAHat/PaperVizAgent` - **OpenClaw plugin** — `openclaw plugins install --link hosts/openclaw` from a checkout; published: `openclaw plugins install clawhub:openclaw-plugin-papervizagent` - **Hermes plugin** — `hermes plugins install https://github.com/GoatInAHat/PaperVizAgent#hosts/hermes/papervizagent_hermes` -- **DSH plugin** (experimental) — `dsh plugin --profile add ./hosts/dsh` from a checkout, or the release tarball `papervizagent-dsh-0.4.0.tgz` +- **DSH plugin** (experimental) — `dsh plugin --profile add ./hosts/dsh` from a checkout, or the release tarball `papervizagent-dsh-0.5.0.tgz` - **Browser extension** — from a checkout: `npm --prefix hosts/browser install && npm --prefix hosts/browser exec --no -- wxt build`, then `chrome://extensions` → developer mode → Load unpacked → `hosts/browser/.output/chrome-mv3` (Firefox: `npm --prefix hosts/browser exec --no -- web-ext run`). Each GitHub Release attaches the - store uploads `papervizagent-0.4.0-chrome.zip`, `papervizagent-0.4.0-firefox.zip`, `papervizagent-0.4.0-edge.zip`. When Firefox signing credentials are configured, it also attaches a + store uploads `papervizagent-0.5.0-chrome.zip`, `papervizagent-0.5.0-firefox.zip`, `papervizagent-0.5.0-edge.zip`. When Firefox signing credentials are configured, it also attaches a Mozilla-signed `.xpi`; the Chrome Web Store, Firefox Add-ons and Edge Add-ons listings appear once the release's submit step has each store's credentials. Then pair it: `uvx papervizagent mcp --http --pair` prints the `#` the extension's options page accepts. - **Web app** — `uvx papervizagent mcp --http --open` serves the operations page beside the MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and returns the URL. -- **npm package** — `npm install papervizagent`; requires `uv`, and `papervizagent` delegates to `uvx --from papervizagent==0.4.0 papervizagent` +- **npm package** — `npm install papervizagent`; requires `uv`, and `papervizagent` delegates to `uvx --from papervizagent==0.5.0 papervizagent` - **PyPI package** — `uv add papervizagent` @@ -45,11 +45,19 @@ The npm package is a small launcher for the same version on PyPI and requires [u The skill resolves each role independently: **explicit role setting → modality setting → available host tool → Codex fallback**. It checks actual tools; a host name does not guarantee vision or image generation. +The shared `model_policy` defaults to `balanced`: native roles use a current +mid-tier alias or live catalog, and Codex selects from its current account +catalog with a recorded reason. Claude Code uses `sonnet` for planning and +image inspection, keeping Codex fallback confined to missing image generation. +Choose `quality` for a highest-quality request (`opus` in Claude); explicit +model and effort choices still win. Catalog tier labels are advisory, with +transparent provider-default fallbacks. See [configuration](CONFIGURATION.md). + On Codex, the skill uses native subagents, vision and image generation without API keys or a Python server. The Codex plugin does not eagerly start MCP. Custom overrides use the optional runtime through the CLI or an explicitly connected MCP server. On other hosts, use available native capabilities and supply `CODEX_OAUTH_TOKEN` for missing capabilities, or use an existing Codex sign-in. `CODEX_ACCOUNT_ID` is needed only if the supplied token does not contain its account identifier. Tokens stay in environment/host secret settings or an explicitly selected token file; never paste them into prompts or tool arguments. Static access tokens expire: their owner must refresh them. The SDK manages refresh for its own sign-in. -A standalone CLI/MCP/web server cannot directly invoke another app’s private tools. Its `generate` operation uses configured SDK providers or Codex. The portable skill is the bridge to host-native tools; embedded Python integrations can also supply native callbacks to `Backend(native={...})`. An unavailable capability produces an error. +A standalone CLI/MCP/web server cannot directly invoke another app’s private tools. Its `generate` operation uses configured SDK providers or Codex. The portable skill is the bridge to host-native tools; embedded Python integrations can also supply native callbacks to `Backend(native={...})`. A callback is awaited with keyword arguments `role`, `modality`, `system`, `contents`, `options`, `model`, and `model_policy`; it must apply a configured `model` when present and return the role output. An unavailable capability produces an error. The official [Codex SDK](https://github.com/openai/codex/tree/main/sdk/python) discovers subscription models and starts a fresh, ephemeral thread for every role call. `models.image.model` selects the Codex **coordinating model**; Codex manages the built-in image generator. Arbitrary image-model selection uses an image API provider. Codex does not expose API controls such as temperature or maximum output tokens; explicit unsupported overrides fail, and unavailable upstream defaults are listed in the trace. Aspect/size requests are prompt guidance on Codex. See [configuration](CONFIGURATION.md). diff --git a/RELEASING.md b/RELEASING.md index e8fb81c..3055f5e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,7 +13,7 @@ and MCPB do not require an existing PyPI package. ## Current publication status -The v0.4.0 GitHub release provides downloadable integrations independently of +GitHub release bundles provide downloadable integrations independently of registry accounts. PyPI and npm publication, ClawHub listings and browser-store submission remain pending their publisher setup. The GitHub `pypi` environment and Pages source are configured; the PyPI trusted-publisher record must still be diff --git a/bin/papervizagent.mjs b/bin/papervizagent.mjs index 2300430..b01b829 100644 --- a/bin/papervizagent.mjs +++ b/bin/papervizagent.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -const child = spawnSync("uvx", ["--from", "papervizagent==0.4.0", "papervizagent", ...process.argv.slice(2)], { +const child = spawnSync("uvx", ["--from", "papervizagent==0.5.0", "papervizagent", ...process.argv.slice(2)], { stdio: "inherit", }); if (child.error) throw child.error; diff --git a/dev.toolfactory/lock.json b/dev.toolfactory/lock.json index a3b017a..0baacb3 100644 --- a/dev.toolfactory/lock.json +++ b/dev.toolfactory/lock.json @@ -58,7 +58,7 @@ ] }, ".claude-plugin/plugin.json": { - "sha256": "f232488d642b84e1ede5d7a37946d5636356a404f84b94547cff26ff028d8cbd", + "sha256": "5e04a437922a3b763a0b9edcf892d45526c52af0b8e08666cd30471e19473f2b", "state": "generated", "keys": [ "$schema", @@ -109,7 +109,7 @@ "state": "generated" }, ".codex-plugin/plugin.json": { - "sha256": "ccfc02cf2168b0c1472cc8916ca65b254da106f6ff7e8ee9e281fb5a7153a904", + "sha256": "690a26b0614cc44befc5c81404250a62bc05f5036f3334f54b430cd6aa00da1c", "state": "generated", "keys": [ "name", @@ -131,7 +131,7 @@ ] }, ".cursor-plugin/plugin.json": { - "sha256": "7398249bdcf942d15f66d3c12307507c3bb15592e63d4b3b3fa91cfcb85e138c", + "sha256": "271122b9bb90d4374e85d25455c40715e7d5799f10d69094552142388ac3bf1e", "state": "generated", "keys": [ "name", @@ -227,7 +227,7 @@ "state": "manual" }, "bin/papervizagent.mjs": { - "sha256": "e77728870f5cb2eed0019fc16f5cdc9a379492852fdeb37d432b4e62f9d5d832", + "sha256": "49f1ee566a9a90f40007dc798ed4133651ae6fc5a1c442c1160246680df10c49", "state": "generated" }, "compose.toolfactory.yaml": { @@ -255,7 +255,7 @@ "state": "manual" }, "gemini-extension.json": { - "sha256": "78abcf22de834de213589befefd48f228208103bbdf7efed1b01c578e0a78852", + "sha256": "4ddd17d40d474dd5ede2f076c294697f4f4fa5839c336b987e13a31fb1413d8a", "state": "generated", "keys": [ "name", @@ -312,7 +312,7 @@ "state": "generated" }, "hosts/browser/package.json": { - "sha256": "dfefd1b5909bedb441394f9f8a76ea150efa48af0156131ace3023e93bbd0161", + "sha256": "e97d6c25f8091bf8b10975c5a47c9a62a49e5a1fa3c59789e82e46619c934ff1", "state": "generated", "keys": [ "name", @@ -405,7 +405,7 @@ ] }, "hosts/dsh/cordis.patch.yml": { - "sha256": "77cbf0305ea06fa814d6ad05ae1bfd0844c2731838f687fccda7a03599bcd11b", + "sha256": "d2560823daccadc9898a6d82136af8bb6ba3b57fe950cf4c84ace898847fac57", "state": "generated", "regions": [ { @@ -415,7 +415,7 @@ ] }, "hosts/dsh/package.json": { - "sha256": "4ae45bb1b484df3f93548cb65f7340f54592611ab3e26551225095293a9a1d99", + "sha256": "ff68f9a68f516d632576a1ea3441e4d08e5efed0ef271267e99f49faf21fb48c", "state": "generated", "keys": [ "name", @@ -427,7 +427,7 @@ ] }, "hosts/hermes/papervizagent_hermes/__init__.py": { - "sha256": "ce74cc0cc27e21e2fb68bbe5cf7392d493b2b15a5837075fe7da03d3145ac729", + "sha256": "dade1712190c1cad9beca72a0b461140b5b72b7c879fc13d10c56d4fc5911781", "state": "generated", "regions": [ { @@ -437,7 +437,7 @@ ] }, "hosts/hermes/papervizagent_hermes/plugin.yaml": { - "sha256": "887ca0b8136a95936e16beb95e481af20fc7c0d37c5b70fada2826b3647307be", + "sha256": "20a4064983d9ee20c7fbcb3d1813e99b6ce896f728cf3d564a23ee6e10c31a91", "state": "generated", "keys": [ "manifest_version", @@ -453,7 +453,7 @@ ] }, "hosts/hermes/pyproject.toml": { - "sha256": "ce6c861244ea2b83fc4288a0ac8dc3b82e0fbce04c6aee130b21a29c48491a33", + "sha256": "604e9678e69b86f2ef224baad83024ca8e62c64db28d7257e883fbd7833d5ecf", "state": "generated", "keys": [ "project.name", @@ -479,7 +479,7 @@ "state": "generated" }, "hosts/mcpb/manifest.json": { - "sha256": "1eb98a562ac5d3a3e3066677e6990e192de936c12465c211200021156818005d", + "sha256": "117c44b000902f78966bbfc204e66600f899739008ee0bcb3e50006e6d520fe6", "state": "generated" }, "hosts/openclaw/.npmrc": { @@ -495,7 +495,7 @@ "state": "generated" }, "hosts/openclaw/openclaw.plugin.json": { - "sha256": "af646fbc0ca4a81d99cd5dae9a53953fcd17ebaa0123f5e77f4e6f2e34048da2", + "sha256": "2aede3e28cf1449e5348124d3510849883956261b64834a4d607be7ff81bc07a", "state": "generated", "keys": [ "id", @@ -528,7 +528,7 @@ ] }, "hosts/openclaw/package.json": { - "sha256": "d0254adfde2b737ebb81483ad4de1c006088af5f16cf5fda17f88dd4c0c8ff90", + "sha256": "1c440fdbd238a775ac9eb830a0c65b87e3ec03180295b665fdcf6dd720612dd8", "state": "generated", "keys": [ "name", @@ -562,7 +562,7 @@ "state": "generated" }, "hosts/openclaw/src/index.ts": { - "sha256": "d450b9116da7cf7542b9963456afb95e4d16154745d8306274b0f1ccc3a2750f", + "sha256": "734a035f017365f6c8e7495405c2afae0e3169f4140115ca7d18a6a434a68cc9", "state": "generated", "regions": [ { @@ -584,7 +584,7 @@ "state": "generated" }, "mcp.json": { - "sha256": "07f1809d8324578e7ca90df6e3157b15bb1172eb2009a875d9c2fc5e8ef85d4c", + "sha256": "a9690afee11caefbda6b723381238c48f810d9e658b2e8142b1dc2272ada0afc", "state": "generated", "keys": [ "$schema", @@ -594,7 +594,7 @@ ] }, "package.json": { - "sha256": "87fca59451f58aa366fcc7c9e7aa63892fc7126868a4ea7882701751807158de", + "sha256": "bad13e4f4efdc0187b0c2c7ec9c74284836063f58abd4f42068383455af58ec4", "state": "generated", "keys": [ "name", @@ -612,7 +612,7 @@ ] }, "pyproject.toml": { - "sha256": "f523d2799e08b825f5c995f181787d02a24b61cf216fc3186a8c6566ef26427d", + "sha256": "4c537d25a493c414eedb3a63dfa8e01853e52f1d90fa3004466c647cb80f7f16", "state": "generated", "keys": [ "project.name", @@ -633,7 +633,7 @@ ] }, "README.md": { - "sha256": "16c0f7180140524133ab26df12061b3e40e2b2c6d93beabb9cd430257e0d15b6", + "sha256": "537800a9211493d2bc9d5dacbfc31f8f2e8aea6e8755cbcf71cb01a091a36e6f", "state": "generated", "regions": [ { @@ -657,11 +657,11 @@ ] }, "server.json": { - "sha256": "bf3be8fe1b0d92b0a2686607e9244a48764ca9cb153f1fc648d0524de12eaad9", + "sha256": "c0d8674787bd164600b9e677a84256b8a8d4a810b9f21e8e388cdd83251edf48", "state": "generated" }, "skills/papervizagent/SKILL.md": { - "sha256": "7dfb020c6c09c46133275b30dc21e34b60c2d4246248be6ff972e12811f06907", + "sha256": "8cdf64f8ca2a17ee14f6af5d6757ca68a28f6f316a9d5d95b548a7dc5ca93400", "state": "generated", "regions": [ { @@ -687,7 +687,7 @@ "state": "generated" }, "src/papervizagent/toolfactory/mcp.py": { - "sha256": "d56d14ee87533952a2c5ff2e9c5b41bb567cc49d618d63bc082cb9b7a7ccf6e9", + "sha256": "e4d7a0fea6e2ef4ae6ed4dc316e64d9d26167fa489aed62ba2163c8b05768078", "state": "generated" }, "src/papervizagent/toolfactory/types.py": { @@ -711,7 +711,7 @@ "state": "generated" }, "web/package.json": { - "sha256": "2bb75e6f57a4829ca5ccef45ebbccab53e1ed31567c499f265d2b4e0f16c9224", + "sha256": "6b9c54554d1f63081180e3e84550b75a4e5c8ace0e2d4d773830f0fef687b224", "state": "generated", "keys": [ "name", @@ -771,7 +771,7 @@ "state": "generated" }, "web/src/ops.json": { - "sha256": "695e840101cda22c9ddee63699a464ededdb05e355031b341863d238508d41f4", + "sha256": "980a66957faf8e53653735d3fe0f827ea087da0c20dc6ad3f07ee4c892362681", "state": "generated" }, "web/src/schema-form.tsx": { diff --git a/dev.toolfactory/ops.json b/dev.toolfactory/ops.json index 50bab73..c285f76 100644 --- a/dev.toolfactory/ops.json +++ b/dev.toolfactory/ops.json @@ -45,6 +45,22 @@ "default": null, "description": "Defaults to pipeline.max_concurrent or 4.", "title": "Max Concurrent" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model Policy" } }, "required": [ @@ -121,6 +137,22 @@ "additionalProperties": true, "title": "Options", "type": "object" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model Policy" } }, "required": [ @@ -179,6 +211,23 @@ }, "title": "Native", "type": "array" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Override the shared balanced default with quality for this request. Explicit model choices still win.", + "title": "Model Policy" } }, "title": "statusArguments" diff --git a/gemini-extension.json b/gemini-extension.json index bfd0722..f462196 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,13 +1,13 @@ { "name": "papervizagent", - "version": "0.4.0", + "version": "0.5.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "mcpServers": { "papervizagent": { "command": "uvx", "args": [ "--from", - "papervizagent==0.4.0", + "papervizagent==0.5.0", "papervizagent", "mcp" ] diff --git a/hosts/browser/package.json b/hosts/browser/package.json index 1438978..e935ce0 100644 --- a/hosts/browser/package.json +++ b/hosts/browser/package.json @@ -2,7 +2,7 @@ "name": "papervizagent-browser", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "private": true, - "version": "0.4.0", + "version": "0.5.0", "type": "module", "scripts": { "dev": "wxt", diff --git a/hosts/dsh/cordis.patch.yml b/hosts/dsh/cordis.patch.yml index ed50089..ad09c0e 100644 --- a/hosts/dsh/cordis.patch.yml +++ b/hosts/dsh/cordis.patch.yml @@ -8,7 +8,7 @@ command: uvx args: - --from - - papervizagent==0.4.0 + - papervizagent==0.5.0 - papervizagent - mcp env: diff --git a/hosts/dsh/package.json b/hosts/dsh/package.json index 01264a1..f2e434a 100644 --- a/hosts/dsh/package.json +++ b/hosts/dsh/package.json @@ -1,6 +1,6 @@ { "name": "papervizagent-dsh", - "version": "0.4.0", + "version": "0.5.0", "description": "DSH bundle for papervizagent: attaches its MCP server through @deepseek-ai/dsh-mcp-client.", "license": "Apache-2.0", "files": [ diff --git a/hosts/hermes/papervizagent_hermes/__init__.py b/hosts/hermes/papervizagent_hermes/__init__.py index 373faee..3a876df 100644 --- a/hosts/hermes/papervizagent_hermes/__init__.py +++ b/hosts/hermes/papervizagent_hermes/__init__.py @@ -67,6 +67,22 @@ "default": null, "description": "Defaults to pipeline.max_concurrent or 4.", "title": "Max Concurrent" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model Policy" } }, "required": [ @@ -133,6 +149,22 @@ "additionalProperties": true, "title": "Options", "type": "object" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model Policy" } }, "required": [ @@ -169,6 +201,23 @@ }, "title": "Native", "type": "array" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Override the shared balanced default with quality for this request. Explicit model choices still win.", + "title": "Model Policy" } }, "title": "statusArguments" diff --git a/hosts/hermes/papervizagent_hermes/plugin.yaml b/hosts/hermes/papervizagent_hermes/plugin.yaml index 3a0bbf7..27ad53d 100644 --- a/hosts/hermes/papervizagent_hermes/plugin.yaml +++ b/hosts/hermes/papervizagent_hermes/plugin.yaml @@ -1,6 +1,6 @@ manifest_version: 1 name: papervizagent -version: 0.4.0 +version: 0.5.0 description: Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available. @@ -33,4 +33,4 @@ optional_env: description: Optional Anthropic API key. secret: true python_dependencies: - - papervizagent>=0.4.0,<1 + - papervizagent>=0.5.0,<1 diff --git a/hosts/hermes/pyproject.toml b/hosts/hermes/pyproject.toml index 50732ad..05df224 100644 --- a/hosts/hermes/pyproject.toml +++ b/hosts/hermes/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "papervizagent-hermes-plugin" -version = "0.4.0" +version = "0.5.0" description = "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available. — Hermes plugin." license = "Apache-2.0" requires-python = ">=3.11" -dependencies = [ "papervizagent>=0.4.0,<1" ] +dependencies = [ "papervizagent>=0.5.0,<1" ] [project.entry-points."hermes_agent.plugins"] papervizagent = "papervizagent_hermes:register" diff --git a/hosts/mcpb/manifest.json b/hosts/mcpb/manifest.json index c3aab36..d1f0a14 100644 --- a/hosts/mcpb/manifest.json +++ b/hosts/mcpb/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.4", "name": "papervizagent", "display_name": "papervizagent", - "version": "0.4.0", + "version": "0.5.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "author": { "name": "GoatInAHat" diff --git a/hosts/openclaw/openclaw.plugin.json b/hosts/openclaw/openclaw.plugin.json index af283c5..d083b87 100644 --- a/hosts/openclaw/openclaw.plugin.json +++ b/hosts/openclaw/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "papervizagent", "name": "Papervizagent", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", - "version": "0.4.0", + "version": "0.5.0", "configSchema": { "type": "object", "properties": { diff --git a/hosts/openclaw/package.json b/hosts/openclaw/package.json index cb0f900..b83e342 100644 --- a/hosts/openclaw/package.json +++ b/hosts/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-plugin-papervizagent", - "version": "0.4.0", + "version": "0.5.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "type": "module", "private": true, diff --git a/hosts/openclaw/src/index.ts b/hosts/openclaw/src/index.ts index 9c4f070..3fb275f 100644 --- a/hosts/openclaw/src/index.ts +++ b/hosts/openclaw/src/index.ts @@ -132,6 +132,22 @@ const entry = defineToolPlugin({ "default": null, "description": "Defaults to pipeline.max_concurrent or 4.", "title": "Max Concurrent" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model Policy" } }, "required": [ @@ -199,6 +215,22 @@ const entry = defineToolPlugin({ "additionalProperties": true, "title": "Options", "type": "object" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model Policy" } }, "required": [ @@ -237,6 +269,23 @@ const entry = defineToolPlugin({ }, "title": "Native", "type": "array" + }, + "model_policy": { + "anyOf": [ + { + "enum": [ + "balanced", + "quality" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Override the shared balanced default with quality for this request. Explicit model choices still win.", + "title": "Model Policy" } }, "title": "statusArguments" diff --git a/mcp.json b/mcp.json index dc42eb2..70fb56c 100644 --- a/mcp.json +++ b/mcp.json @@ -6,7 +6,7 @@ "command": "uvx", "args": [ "--from", - "papervizagent==0.4.0", + "papervizagent==0.5.0", "papervizagent", "mcp" ] diff --git a/package.json b/package.json index c5463aa..3e1c39d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "papervizagent", - "version": "0.4.0", + "version": "0.5.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "license": "Apache-2.0", "homepage": "https://github.com/GoatInAHat/PaperVizAgent", diff --git a/plugin.json b/plugin.json index 1329e35..71d3636 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "papervizagent", - "version": "0.4.0", + "version": "0.5.0", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", "license": "Apache-2.0", "repository": "https://github.com/GoatInAHat/PaperVizAgent", diff --git a/pyproject.toml b/pyproject.toml index a0b65fb..208ba1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "papervizagent" -version = "0.4.0" +version = "0.5.0" description = "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available." readme = "README.md" requires-python = ">=3.11" diff --git a/server.json b/server.json index 3160f9b..23f1e38 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.GoatInAHat/papervizagent", "description": "Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and", - "version": "0.4.0", + "version": "0.5.0", "repository": { "url": "https://github.com/GoatInAHat/PaperVizAgent", "source": "github" @@ -12,7 +12,7 @@ { "registryType": "pypi", "identifier": "papervizagent", - "version": "0.4.0", + "version": "0.5.0", "transport": { "type": "stdio" }, @@ -49,7 +49,7 @@ }, { "registryType": "oci", - "identifier": "ghcr.io/goatinahat/papervizagent:0.4.0", + "identifier": "ghcr.io/goatinahat/papervizagent:0.5.0", "transport": { "type": "stdio" }, diff --git a/skills/papervizagent/SKILL.md b/skills/papervizagent/SKILL.md index da2bb31..f078d92 100644 --- a/skills/papervizagent/SKILL.md +++ b/skills/papervizagent/SKILL.md @@ -28,7 +28,9 @@ returned worker/task ID, then return control to the user. Resume delivery on the worker's completion or failure notification. Keep intermediate role results and logs in the worker; surface only a required user decision or the reviewed result. Do not create scheduled check-ins, heartbeat automations, sleep loops, or repeated -file/status reads to discover completion. A user-requested status check can read +file/status reads to discover completion. In Claude Code, yield after Agent +dispatch and resume from its automatic completion notification; never use +ScheduleWakeup or a follow-up message to check whether a role has finished. A user-requested status check can read the current state once without restarting work. For runtime execution, the background worker awaits `generate` once. Embedded @@ -72,6 +74,27 @@ input files, its original prompt, and the adapter instructions in [roles.md](references/roles.md). In both cases, apply an explicit user override only when the selected runtime configuration permits it. +Use the returned `model_policy` (default `balanced`) for every role without an +explicit model override. A request for highest possible quality selects `quality`; +pass that policy to `status`, `infer`, and `generate` for the run. For native roles, +choose the host's current general-purpose mid-tier model using its supported +moving alias or live catalog. In Claude Code, use `sonnet` for balanced work and +`opus` for quality, including image inspection through Read; verify the actual +tools and record the resolved model. Do not inherit an expensive coordinator's +model automatically. Other hosts must use their own verified catalog or alias, +not Claude's names. If no tier information is exposed, use the host default and +record that fallback. Never guess a newly released model ID or silently replace +an explicit choice when it retires. + +Keep effort moderate for routine text/vision roles and low for an image-tool +coordinator, using only controls the selected host/model supports. Explicit +model and effort overrides take precedence. The shared Codex runtime uses its +live account catalog and records its selection reason; catalog labels are +advisory and do not prove cost or benchmark quality. Codex subscription images +are service-managed: `model` selects the small coordinating request, not the +underlying image generator. Do not claim a pinned image-model version when the +interface neither selects nor reports it. + Pass the same caption/title constraint to all roles: caption and overall title stay outside the image unless explicitly requested inside; supported phase and group labels are allowed. Check descriptions at handoff for contradictory @@ -178,7 +201,7 @@ full pipeline. Run upstream PaperVizAgent end to end, with configurable roles, modes, retrieval, candidates and critic rounds. Plot mode executes generated Python in a bounded subprocess. -Arguments: `data`, `settings`, `num_candidates`, `max_concurrent`. +Arguments: `data`, `settings`, `num_candidates`, `max_concurrent`, `model_policy`. `papervizagent generate --json ''` prints a JSON result. MCP tool `generate` on server `papervizagent` returns the same result as `structuredContent`. @@ -186,7 +209,7 @@ Arguments: `data`, `settings`, `num_candidates`, `max_concurrent`. Run one isolated PaperVizAgent role using its configured provider or Codex fallback. Returns text or an image file and request trace. -Arguments: `role`, `modality`, `system`, `contents`, `contents_file`, `options`. +Arguments: `role`, `modality`, `system`, `contents`, `contents_file`, `options`, `model_policy`. `papervizagent infer --json ''` prints a JSON result. MCP tool `infer` on server `papervizagent` returns the same result as `structuredContent`. @@ -200,7 +223,7 @@ Read models and capabilities available through the configured Codex subscription Resolve per-role model routing using optional configuration and verified host capabilities. Does not call a model. -Arguments: `native`. +Arguments: `native`, `model_policy`. `papervizagent status --json ''` prints a JSON result. MCP tool `status` on server `papervizagent` returns the same result as `structuredContent`. diff --git a/skills/papervizagent/references/compatibility.md b/skills/papervizagent/references/compatibility.md index 3db6a7c..c11ff41 100644 --- a/skills/papervizagent/references/compatibility.md +++ b/skills/papervizagent/references/compatibility.md @@ -3,7 +3,7 @@ Pinned official source: google-research/papervizagent at e088a8fff74cc363b6897c0843631fff76484908. The original agents are separate role-specific model calls orchestrated in one Python process. This port uses -fresh native Codex subagents and persisted artifact handoffs. +fresh native host subagents and persisted artifact handoffs. ## Restored from v0.1.0 @@ -27,6 +27,13 @@ fresh native Codex subagents and persisted artifact handoffs. alone does not establish text, vision, delegation, or image capability, and model sampling, exact pixel resolution, and benchmark parity cannot be promised. +- Model policy: balanced work uses current host mid-tier aliases or advisory + provider catalog labels, with provider defaults as an explicit fallback. + Highest-quality requests opt into the quality policy. Explicit model/effort + choices remain authoritative. This changes provider selection and effort, + not the pinned upstream role prompts or style guides. Codex subscription + images are managed by the service; the coordinating model is selectable, + but the underlying image-model ID cannot be pinned or verified there. - Configuration and settings: runtime configuration is loaded from the `PAPERVIZAGENT_CONFIG` JSON/YAML path and environment, never from an authentication-token argument. The adapter passes complete upstream request diff --git a/src/papervizagent/backends.py b/src/papervizagent/backends.py index 2492c30..10905b7 100644 --- a/src/papervizagent/backends.py +++ b/src/papervizagent/backends.py @@ -12,6 +12,7 @@ from uuid import uuid4 from .config import Model, Settings +from .model_selection import automatic_effort, select_model, selected_entry, validate_controls def image_source(block: dict) -> dict: @@ -97,25 +98,24 @@ async def codex(self): @staticmethod async def _model_catalog(client): from openai_codex.generated.v2_all import ModelListResponse - result = await client.model_list() + # Hidden entries remain excluded from automatic policy selection, but + # an explicit user choice may legitimately target one if the account + # catalog reports it as available. + result = await client.model_list(include_hidden=True) values = result.model_dump(mode='json', by_alias=True) models = values['data'] while values.get('nextCursor'): - result = await client.request('model/list', {'cursor': values['nextCursor']}, response_model=ModelListResponse) + result = await client.request('model/list', {'cursor': values['nextCursor'], 'includeHidden': True}, response_model=ModelListResponse) values = result.model_dump(mode='json', by_alias=True) models.extend(values['data']) return models + def select_model(self, spec: Model, modality: str): + return select_model(self.models, modality=modality, policy=self.settings.model_policy, explicit=spec.model) + def choose_model(self, spec: Model, modality: str) -> str: - eligible = [m for m in self.models if modality != 'vlm' or 'image' in m['inputModalities']] - if spec.model: - if not any(spec.model in (m['id'], m['model']) for m in eligible): - raise ValueError(f'Codex model {spec.model!r} is unavailable for {modality}. Use models to inspect the account catalog. For image, model selects the Codex coordinator; its built-in image model is service-managed.') - return spec.model - default = next((m for m in eligible if m.get('isDefault')), None) - if not eligible: - raise ValueError(f'No available Codex model supports {modality}.') - return (default or eligible[0])['model'] + """Compatibility helper for callers that only need the coordinator ID.""" + return self.select_model(spec, modality).model async def generate(self, role: str, modality: str, system: str, contents: list[dict], options: dict | None = None) -> list[str]: spec = self.settings.resolve(role, modality, tuple(self.native)) @@ -123,8 +123,13 @@ async def generate(self, role: str, modality: str, system: str, contents: list[d if spec.provider == 'native': if modality not in self.native: raise ValueError(f'{role}/{modality} is assigned to the host. Run this role using the skill and the host tool, or provide a native callback to Backend.') - result = await self.native[modality](role=role, modality=modality, system=system, contents=contents, options=merged) - self.trace.append({'role': role, 'modality': modality, 'provider': 'native', 'model': spec.model}) + result = await self.native[modality]( + role=role, modality=modality, system=system, contents=contents, + options=merged, model=spec.model, + model_policy=self.settings.model_policy, + ) + self.trace.append({'role': role, 'modality': modality, 'provider': 'native', + 'model': spec.model, 'model_policy': self.settings.model_policy}) return result if spec.provider == 'codex': return await asyncio.wait_for(self._codex_generate(spec, role, modality, system, contents, merged), self.settings.codex.timeout) @@ -138,11 +143,19 @@ async def generate(self, role: str, modality: str, system: str, contents: list[d async def _codex_generate(self, spec, role, modality, system, contents, options): from openai_codex.generated.v2_all import ModelProviderCapabilitiesReadResponse client = await self.codex() - model = self.choose_model(spec, 'vlm' if any(b.get('type') == 'image' for b in contents) else modality) + selected_modality = 'vlm' if any(b.get('type') == 'image' for b in contents) else modality + selection = self.select_model(spec, selected_modality) + model = selection.model supported = {'effort', 'service_tier', 'output_schema', 'aspect_ratio', 'image_size', 'size', 'quality', 'background'} unsupported = set(spec.options) - supported if unsupported: raise ValueError(f'Codex does not expose these model options: {sorted(unsupported)}. Select an API provider for these controls.') + entry = selected_entry(self.models, model) + if 'effort' not in options: + effort, _ = automatic_effort(entry, policy=self.settings.model_policy, modality=modality) + if effort: + options = {**options, 'effort': effort} + validate_controls(entry, options) if modality == 'image': caps = await client.request('modelProvider/capabilities/read', {}, response_model=ModelProviderCapabilitiesReadResponse) if not caps.image_generation: @@ -163,10 +176,16 @@ async def _codex_generate(self, spec, role, modality, system, contents, options) 'approvalPolicy': 'never', 'sandbox': 'read-only', 'developerInstructions': instruction, 'config': self.settings.codex.config, }) - params = {k: options[key] for key, k in [('effort', 'effort'), ('service_tier', 'serviceTier'), ('output_schema', 'outputSchema')] if key in options} + params = {k: options[key] for key, k in [('effort', 'effort'), ('service_tier', 'serviceTier'), ('output_schema', 'outputSchema')] if options.get(key) is not None} turn = await client.turn_start(thread.thread.id, items, params) tid = turn.turn.id - record = {'role': role, 'modality': modality, 'provider': 'codex', 'model': model, 'thread_id': thread.thread.id, 'turn_id': tid} + record = {'role': role, 'modality': modality, 'provider': 'codex', 'model': model, + 'model_policy': self.settings.model_policy, + 'model_selection_reason': selection.reason, + 'model_classification': selection.classification, + 'effort': options.get('effort'), + 'service_tier': options.get('service_tier'), + 'thread_id': thread.thread.id, 'turn_id': tid} record['unavailable_upstream_options'] = sorted(set(options) - supported) self.trace.append(record) texts, images = [], [] diff --git a/src/papervizagent/config.py b/src/papervizagent/config.py index ea631d4..d8c9560 100644 --- a/src/papervizagent/config.py +++ b/src/papervizagent/config.py @@ -11,6 +11,7 @@ Modality = Literal['llm', 'vlm', 'image'] Role = Literal['retriever', 'planner', 'stylist', 'visualizer', 'critic', 'vanilla', 'polish'] Provider = Literal['native', 'codex', 'gemini', 'openai', 'anthropic'] +ModelPolicy = Literal['balanced', 'quality'] class Model(BaseModel): @@ -40,6 +41,7 @@ class Settings(BaseModel): model_config = ConfigDict(extra='forbid', hide_input_in_errors=True) models: dict[Modality, Model] = Field(default_factory=dict) roles: dict[Role, Model] = Field(default_factory=dict) + model_policy: ModelPolicy = 'balanced' codex: CodexAuth = Field(default_factory=CodexAuth) pipeline: dict[str, Any] = Field(default_factory=dict) @@ -92,5 +94,5 @@ def _upstream_config(value: dict) -> dict: if secret: spec['api_key'] = secret models[modality] = spec - return {**{key: value[key] for key in ('roles', 'codex', 'pipeline') if key in value}, + return {**{key: value[key] for key in ('roles', 'model_policy', 'codex', 'pipeline') if key in value}, 'models': {**models, **value.get('models', {})}} diff --git a/src/papervizagent/model_selection.py b/src/papervizagent/model_selection.py new file mode 100644 index 0000000..b3d2b49 --- /dev/null +++ b/src/papervizagent/model_selection.py @@ -0,0 +1,110 @@ +"""Catalog-driven Codex coordinator selection without hard-coded model IDs.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from .config import ModelPolicy + + +@dataclass(frozen=True) +class ModelSelection: + model: str + reason: str + classification: str | None = None + + +_LEGACY_MARKERS = ('previous-generation', 'deprecated', 'retired') +_POLICY_MARKERS = { + 'balanced': ('balanced', 'mid-tier'), + 'quality': ('most capable', 'flagship', 'frontier'), +} + + +def _text(entry: dict) -> str: + return ' '.join(str(entry.get(key) or '') for key in ('description', 'displayName')).lower() + + +def _is_legacy(entry: dict) -> bool: + text = _text(entry) + return bool(entry.get('upgrade') or entry.get('upgradeInfo')) or any(marker in text for marker in _LEGACY_MARKERS) + + +def _capable(entries: list[dict], modality: str) -> list[dict]: + return [ + entry for entry in entries + if modality != 'vlm' or 'image' in (entry.get('inputModalities') or ()) + ] + + +def _eligible(entries: list[dict], modality: str) -> list[dict]: + return [ + entry for entry in _capable(entries, modality) + if not entry.get('hidden') + and not entry.get('modelSpecialty') + ] + + +def _current(entries: list[dict]) -> list[dict]: + current = [entry for entry in entries if not _is_legacy(entry)] + return current or entries + + +def select_model(entries: list[dict], *, modality: str, policy: ModelPolicy, + explicit: str | None = None) -> ModelSelection: + """Select a visible general coordinator from the account's current catalog. + + Catalog descriptions are advisory labels, not cost or benchmark metadata. + They are used only when a provider explicitly labels a model balanced or + quality-oriented; otherwise the provider's default remains authoritative. + """ + if explicit: + selected = next((entry for entry in _capable(entries, modality) if explicit in (entry.get('id'), entry.get('model'))), None) + if selected is None: + raise ValueError(f'Codex model {explicit!r} is unavailable for {modality}. Use models to inspect the account catalog. For image, model selects the Codex coordinator; its built-in image model is service-managed.') + return ModelSelection(selected['model'], 'explicit', 'explicit') + eligible = _eligible(entries, modality) + if not eligible: + raise ValueError(f'No available visible general Codex model supports {modality}.') + + current = _current(eligible) + markers = _POLICY_MARKERS[policy] + classified = [entry for entry in current if any(marker in _text(entry) for marker in markers)] + if classified: + # The provider's catalog order is the final tie-breaker. Prefer a + # current entry without migration metadata when labels otherwise tie. + selected = next((entry for entry in classified if not _is_legacy(entry)), classified[0]) + return ModelSelection(selected['model'], f'{policy}_catalog_label', policy) + default = next((entry for entry in current if entry.get('isDefault')), None) + if default: + return ModelSelection(default['model'], 'server_default_fallback', None) + return ModelSelection(current[0]['model'], 'catalog_order_fallback', None) + + +def automatic_effort(entry: dict, *, policy: ModelPolicy, modality: str) -> tuple[str | None, str | None]: + """Choose a supported non-delegating effort for policy-selected calls.""" + supported = { + value.get('reasoningEffort') + for value in entry.get('supportedReasoningEfforts') or () + if value.get('reasoningEffort') + } + preferences = ('high', 'medium', 'low') if policy == 'quality' else ( + ('low', 'medium') if modality == 'image' else ('medium', 'low') + ) + effort = next((value for value in preferences if value in supported), None) + return effort, 'policy_effort' if effort else None + + +def selected_entry(entries: list[dict], model: str) -> dict: + return next(entry for entry in entries if model in (entry.get('id'), entry.get('model'))) + + +def validate_controls(entry: dict, options: dict) -> None: + if options.get('effort') is not None: + supported = {value.get('reasoningEffort') for value in entry.get('supportedReasoningEfforts') or ()} + if options['effort'] not in supported: + raise ValueError(f"Codex model {entry['model']!r} does not support effort {options['effort']!r}.") + if options.get('service_tier') is not None: + supported = {value.get('id') for value in entry.get('serviceTiers') or ()} + if options['service_tier'] not in supported: + raise ValueError(f"Codex model {entry['model']!r} does not support service_tier {options['service_tier']!r}.") diff --git a/src/papervizagent/ops.py b/src/papervizagent/ops.py index 11efc48..a6c45a6 100644 --- a/src/papervizagent/ops.py +++ b/src/papervizagent/ops.py @@ -14,13 +14,14 @@ from pydantic import BaseModel, ConfigDict, Field from .backends import Backend -from .config import Modality, Role, load_settings +from .config import Modality, Model, ModelPolicy, Role, load_settings from .events import EventSink, ObservedBackend, RunEvents from .toolfactory import Context, Operation class StatusInput(BaseModel): native: list[Modality] = Field(default_factory=list, description='Capabilities verified by the calling host; never inferred from the host name.') + model_policy: ModelPolicy | None = Field(default=None, description='Override the shared balanced default with quality for this request. Explicit model choices still win.') class EmptyInput(BaseModel): @@ -34,6 +35,7 @@ class InferInput(BaseModel): contents: list[dict[str, Any]] = Field(default_factory=list) contents_file: str | None = Field(default=None, description='Optional local JSON file containing content blocks, useful for image handoffs.') options: dict[str, Any] = Field(default_factory=dict) + model_policy: ModelPolicy | None = None class GenerateInput(BaseModel): @@ -41,10 +43,14 @@ class GenerateInput(BaseModel): settings: dict[str, Any] = Field(default_factory=dict, description='Upstream pipeline settings; overrides the configuration file pipeline section. No credentials.') num_candidates: int | None = Field(default=None, ge=1, description='Defaults to pipeline.num_candidates or 1.') max_concurrent: int | None = Field(default=None, ge=1, description='Defaults to pipeline.max_concurrent or 4.') + model_policy: ModelPolicy | None = None -def settings_for(ctx: Context): - return load_settings(ctx.config.get('papervizagent_config')) +def settings_for(ctx: Context, model_policy: ModelPolicy | None = None): + settings = load_settings(ctx.config.get('papervizagent_config')) + if model_policy is not None: + settings.model_policy = model_policy + return settings # Only native generation controls need to cross the model-visible host boundary. @@ -55,7 +61,7 @@ def settings_for(ctx: Context): def status(args: StatusInput, ctx: Context) -> dict: - settings = settings_for(ctx) + settings = settings_for(ctx, args.model_policy) routes = {} for role in ('retriever', 'planner', 'stylist', 'visualizer', 'critic', 'vanilla', 'polish'): routes[role] = {} @@ -70,6 +76,7 @@ def status(args: StatusInput, ctx: Context) -> dict: return { 'routes': routes, 'native': args.native, + 'model_policy': settings.model_policy, 'codex': 'Use models to verify subscription availability; host capabilities are supplied by the caller.', 'pipeline': {key: settings.pipeline[key] for key in ('task_name', 'exp_mode', 'retrieval_setting', 'temperature', @@ -84,7 +91,16 @@ async def models(args: EmptyInput, ctx: Context) -> dict: async with Backend(settings_for(ctx)) as backend: client = await backend.codex() caps = await client.request('modelProvider/capabilities/read', {}, response_model=ModelProviderCapabilitiesReadResponse) + defaults = {} + for modality in ('llm', 'vlm', 'image'): + try: + selection = backend.select_model(Model(), modality) + defaults[modality] = {'model': selection.model, 'reason': selection.reason, + 'classification': selection.classification} + except ValueError as error: + defaults[modality] = {'unavailable': str(error)} return {'models': backend.models, 'capabilities': caps.model_dump(by_alias=True), + 'model_policy': backend.settings.model_policy, 'defaults': defaults, 'image_model': 'Managed by Codex; models.image.model selects the coordinating model.'} @@ -98,7 +114,7 @@ async def infer(args: InferInput, ctx: Context) -> dict: contents = json.loads(Path(args.contents_file).expanduser().read_text()) if args.contents_file else args.contents if not contents: raise ValueError('Supply contents or contents_file.') - configured = settings_for(ctx) + configured = settings_for(ctx, args.model_policy) spec = configured.resolve(args.role, args.modality) spec.options = {**spec.options, **args.options} configured.roles[args.role] = spec @@ -121,7 +137,7 @@ async def infer(args: InferInput, ctx: Context) -> dict: async def generate(args: GenerateInput, ctx: Context, *, on_event: EventSink | None = None) -> dict: from .pipeline import run_pipeline - configured = settings_for(ctx) + configured = settings_for(ctx, args.model_policy) options = {**configured.pipeline, **args.settings} num_candidates = args.num_candidates or int(options.pop('num_candidates', 1)) max_concurrent = args.max_concurrent or int(options.pop('max_concurrent', 4)) diff --git a/src/papervizagent/toolfactory/mcp.py b/src/papervizagent/toolfactory/mcp.py index 0402d47..60991d1 100644 --- a/src/papervizagent/toolfactory/mcp.py +++ b/src/papervizagent/toolfactory/mcp.py @@ -33,7 +33,7 @@ server = MCPServer( name="papervizagent", - version="0.4.0", + version="0.5.0", description="Scientific figures with upstream PaperVizAgent, separate roles, configurable model providers and Codex subscription fallback. Host-native defaults where tools are available.", ) diff --git a/tests/test_operation_artifacts.py b/tests/test_operation_artifacts.py index c869572..8432006 100644 --- a/tests/test_operation_artifacts.py +++ b/tests/test_operation_artifacts.py @@ -2,7 +2,7 @@ import base64 from io import BytesIO from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from PIL import Image import pytest @@ -39,11 +39,35 @@ def test_status_hands_safe_native_overrides_to_host(tmp_path): 'pipeline': {'work_dir': '/papers/custom', 'dataset_name': 'PaperBananaBench', 'split_name': 'demo', 'timestamp': 'run-1', 'auth_token': 'private-pipeline'}, })) - result = status(StatusInput(native=['llm', 'vlm']), + result = status(StatusInput(native=['llm', 'vlm'], model_policy='quality'), Context(config={'papervizagent_config': str(config)}, data_dir=tmp_path)) + assert result['model_policy'] == 'quality' + assert result['routes']['planner']['llm']['provider'] == 'native' + assert result['routes']['visualizer']['image']['provider'] == 'codex' critic = result['routes']['critic']['vlm'] assert critic['options'] == {'effort': 'high'} assert critic['unavailable_options'] == ['api_token', 'extra_headers'] assert result['pipeline']['work_dir'] == '/papers/custom' assert result['pipeline']['split_name'] == 'demo' assert 'private-' not in json.dumps(result) + + +async def test_models_reports_missing_vision_without_hiding_catalog(tmp_path): + from papervizagent.model_selection import ModelSelection + from papervizagent.ops import EmptyInput, models + with patch('papervizagent.ops.Backend') as cls: + backend = cls.return_value.__aenter__.return_value + backend.models = [{'id': 'text-only', 'model': 'text-only'}] + backend.settings.model_policy = 'balanced' + backend.codex = AsyncMock() + backend.codex.return_value.request = AsyncMock() + backend.codex.return_value.request.return_value = Mock() + backend.codex.return_value.request.return_value.model_dump.return_value = {'imageGeneration': False} + backend.select_model = Mock(side_effect=[ModelSelection('text-only', 'server_default_fallback'), + ValueError('No vision model'), + ModelSelection('text-only', 'server_default_fallback')]) + result = await models(EmptyInput(), Context(config={}, data_dir=tmp_path)) + assert result['models'] == backend.models + assert result['defaults']['vlm'] == {'unavailable': 'No vision model'} + assert result['defaults']['llm']['model'] == 'text-only' + assert result['capabilities']['imageGeneration'] is False diff --git a/tests/test_routing.py b/tests/test_routing.py index 829bcc3..f4f73e4 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -28,14 +28,30 @@ def test_explicit_routes_win_over_available_native_capability(self): self.assertEqual(settings.resolve("visualizer", "image", ("image",)).provider, "native") self.assertEqual(settings.resolve("visualizer", "vlm", ("image",)).provider, "codex") + def test_unconfigured_routes_keep_every_verified_native_modality(self): + settings = Settings() + for modality in ("llm", "vlm", "image"): + self.assertEqual(settings.resolve("visualizer", modality, ("llm", "vlm", "image")).provider, "native") + async def test_native_callback_and_codex_fallback_are_selected_per_modality(self): received = [] - async def native(**request): - received.append(request) + async def native(*, role, modality, system, contents, options, model, model_policy): + received.append({ + "role": role, + "modality": modality, + "system": system, + "contents": contents, + "options": options, + "model": model, + "model_policy": model_policy, + }) return ["host answer"] - backend = Backend(native={"llm": native}) + backend = Backend(Settings( + model_policy="quality", + roles={"planner": Model(provider="native", model="host-quality-model")}, + ), native={"llm": native}) backend._codex_generate = AsyncMock(return_value=["codex answer"]) text = [{"type": "text", "text": "paper"}] @@ -43,8 +59,11 @@ async def native(**request): self.assertEqual(await backend.generate("critic", "vlm", "inspect", text), ["codex answer"]) self.assertEqual(received[0]["role"], "planner") self.assertEqual(received[0]["options"], {}) + self.assertEqual(received[0]["model"], "host-quality-model") + self.assertEqual(received[0]["model_policy"], "quality") backend._codex_generate.assert_awaited_once() - self.assertEqual([entry["provider"] for entry in backend.trace], ["native"]) + self.assertEqual(backend.trace, [{"role": "planner", "modality": "llm", "provider": "native", + "model": "host-quality-model", "model_policy": "quality"}]) def test_vision_model_selection_requires_image_input_and_honors_default(self): backend = Backend() @@ -55,10 +74,107 @@ def test_vision_model_selection_requires_image_input_and_honors_default(self): ] self.assertEqual(backend.choose_model(Model(), "vlm"), "vision-default") - self.assertEqual(backend.choose_model(Model(model="vision"), "vlm"), "vision") + self.assertEqual(backend.choose_model(Model(model="vision"), "vlm"), "vision-model") with self.assertRaisesRegex(ValueError, "unavailable for vlm"): backend.choose_model(Model(model="text"), "vlm") + def test_balanced_policy_beats_an_expensive_server_default_without_model_name_heuristics(self): + backend = Backend() + backend.models = [ + {"id": "opaque-default", "model": "opaque-default", "description": "Most capable general model.", "inputModalities": ["text", "image"], "isDefault": True, "supportedReasoningEfforts": []}, + {"id": "future-name", "model": "future-model", "description": "Balanced general model for everyday work.", "inputModalities": ["text", "image"], "isDefault": False, "supportedReasoningEfforts": []}, + ] + selected = backend.select_model(Model(), "llm") + self.assertEqual((selected.model, selected.reason, selected.classification), ("future-model", "balanced_catalog_label", "balanced")) + + def test_quality_policy_prefers_advertised_general_quality_and_excludes_specialty_hidden_and_legacy(self): + backend = Backend(Settings(model_policy="quality")) + backend.models = [ + {"id": "old", "model": "old", "description": "Previous-generation flagship.", "inputModalities": ["text", "image"], "isDefault": True, "supportedReasoningEfforts": []}, + {"id": "cyber", "model": "cyber", "description": "Most capable frontier model.", "modelSpecialty": "cyber", "inputModalities": ["text", "image"], "isDefault": False, "supportedReasoningEfforts": []}, + {"id": "hidden", "model": "hidden", "description": "Most capable frontier model.", "hidden": True, "inputModalities": ["text", "image"], "isDefault": False, "supportedReasoningEfforts": []}, + {"id": "current", "model": "current", "description": "Flagship general model.", "inputModalities": ["text", "image"], "isDefault": False, "supportedReasoningEfforts": []}, + ] + selected = backend.select_model(Model(), "vlm") + self.assertEqual((selected.model, selected.reason), ("current", "quality_catalog_label")) + + def test_unknown_catalog_labels_fall_back_to_server_default_and_explicit_ids_are_canonicalized(self): + backend = Backend() + backend.models = [ + {"id": "retired", "model": "retired-model", "description": "Deprecated general model.", "inputModalities": ["text", "image"], "isDefault": True, "supportedReasoningEfforts": []}, + {"id": "new-id", "model": "canonical-new", "description": "General work.", "inputModalities": ["text", "image"], "isDefault": True, "supportedReasoningEfforts": []}, + ] + selected = backend.select_model(Model(), "llm") + self.assertEqual((selected.model, selected.reason), ("canonical-new", "server_default_fallback")) + explicit = backend.select_model(Model(model="new-id"), "llm") + self.assertEqual((explicit.model, explicit.reason), ("canonical-new", "explicit")) + + def test_explicit_model_can_use_hidden_or_specialized_catalog_entries_when_capable(self): + backend = Backend() + backend.models = [ + {"id": "hidden-specialist", "model": "hidden-specialist", "hidden": True, + "modelSpecialty": "science", "inputModalities": ["text", "image"], + "description": "Specialized model.", "isDefault": False}, + {"id": "text-only", "model": "text-only", "inputModalities": ["text"], + "description": "General model.", "isDefault": True}, + ] + self.assertEqual(backend.choose_model(Model(model="hidden-specialist"), "vlm"), "hidden-specialist") + with self.assertRaisesRegex(ValueError, "unavailable for vlm"): + backend.choose_model(Model(model="text-only"), "vlm") + + def test_quality_policy_uses_supported_effort_and_explicit_controls_validate_before_thread(self): + backend = Backend(Settings(model_policy="quality")) + backend.models = [{ + "id": "quality", "model": "quality", "description": "Flagship general model.", + "inputModalities": ["text", "image"], "isDefault": True, + "supportedReasoningEfforts": [{"reasoningEffort": "medium"}, {"reasoningEffort": "high"}], + "serviceTiers": [{"id": "priority"}], + }] + selected = backend.select_model(Model(), "llm") + from papervizagent.model_selection import automatic_effort, validate_controls + self.assertEqual(automatic_effort(backend.models[0], policy="quality", modality="llm")[0], "high") + with self.assertRaisesRegex(ValueError, "does not support effort"): + validate_controls(backend.models[0], {"effort": "ultra"}) + with self.assertRaisesRegex(ValueError, "does not support service_tier"): + validate_controls(backend.models[0], {"service_tier": "slow"}) + validate_controls(backend.models[0], {"effort": None, "service_tier": None}) + self.assertEqual(selected.model, "quality") + + async def test_catalog_selection_reason_and_policy_effort_are_recorded_on_codex_turn(self): + completed_text = types.SimpleNamespace( + method="item/completed", + payload=types.SimpleNamespace(item=types.SimpleNamespace(root=types.SimpleNamespace( + type="agentMessage", text="done" + ))), + ) + completed_turn = types.SimpleNamespace( + method="turn/completed", + payload=types.SimpleNamespace(turn=types.SimpleNamespace(status=types.SimpleNamespace(value="completed"))), + ) + backend = Backend(Settings(model_policy="balanced")) + backend.models = [{ + "id": "opaque", "model": "future-balanced", "description": "Balanced general model.", + "inputModalities": ["text", "image"], "isDefault": False, + "supportedReasoningEfforts": [{"reasoningEffort": "low"}, {"reasoningEffort": "medium"}], + "serviceTiers": [], + }] + client = _TurnClient([completed_text, completed_turn, completed_text, completed_turn]) + backend.client = client + backend._directory = tempfile.TemporaryDirectory() + try: + await backend._codex_generate(Model(provider="codex"), "planner", "llm", "system", [{"type": "text", "text": "x"}], {}) + self.assertEqual(client.turn_params, [{"effort": "medium"}]) + self.assertEqual(backend.trace[-1]["model"], "future-balanced") + self.assertEqual(backend.trace[-1]["model_policy"], "balanced") + self.assertEqual(backend.trace[-1]["effort"], "medium") + self.assertEqual(backend.trace[-1]["model_selection_reason"], "balanced_catalog_label") + await backend._codex_generate(Model(provider="codex", options={"effort": None, "service_tier": None}), "planner", "llm", "system", [{"type": "text", "text": "x"}], {"effort": None, "service_tier": None}) + self.assertEqual(client.turn_params[-1], {}) + self.assertIsNone(backend.trace[-1]["effort"]) + self.assertIsNone(backend.trace[-1]["service_tier"]) + finally: + backend._directory.cleanup() + def test_oauth_env_jwt_and_token_file_are_read_only_and_not_logged(self): account = "acct-from-jwt" payload = base64.urlsafe_b64encode(json.dumps({ @@ -200,6 +316,7 @@ def __init__(self, events): self.events = list(events) self.thread_requests = [] self.unregistered = [] + self.turn_params = [] self._threads = 0 self._turns = 0 @@ -215,6 +332,7 @@ async def thread_start(self, request): async def turn_start(self, _thread_id, _items, _params): self._turns += 1 + self.turn_params.append(_params) return types.SimpleNamespace(turn=types.SimpleNamespace(id=f"turn-{self._turns}")) async def next_turn_notification(self, _turn_id): diff --git a/uv.lock b/uv.lock index d425b2d..04372af 100644 --- a/uv.lock +++ b/uv.lock @@ -1279,7 +1279,7 @@ wheels = [ [[package]] name = "papervizagent" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "anthropic", extra = ["vertex"] }, diff --git a/web/package.json b/web/package.json index 761b4a4..a65ce5b 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "papervizagent-web", "private": true, - "version": "0.4.0", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite",