feat: introduced Effort enum and ThinkingConfig for model-aware reasoning

- Introduced Effort enum and ThinkingConfig metadata for per-model reasoning capabilities with min/max effort levels.
- Migrated thinking level API from string-based ThinkingLevel to structured Effort enum across agent and AI packages.
- Added model-thinking module with effort mapping, policy application, and semantic versioning utilities for provider-specific thinking modes.
- Removed supportsXhigh() function and replaced effort clamping with model-aware validation using ThinkingConfig metadata.
- Expanded models.json with thinking configuration objects for 50+ models including Claude, Gemini, and OpenAI variants.
- Added Python analysis scripts for edit tool usage patterns and tool invocation stream processing.
This commit is contained in:
can1357
2026-03-06 12:32:37 +01:00
parent b696842570
commit 8e3e0ebf9e
85 changed files with 8303 additions and 1863 deletions
+46 -46
View File
@@ -187,59 +187,59 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1002.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/credential-provider-node": "^3.972.16", "@aws-sdk/eventstream-handler-node": "^3.972.9", "@aws-sdk/middleware-eventstream": "^3.972.6", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/middleware-websocket": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/token-providers": "3.1002.0", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/eventstream-serde-config-resolver": "^4.3.10", "@smithy/eventstream-serde-node": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-xUmzgTvTeQFVxBqla8U4nXpZNXLcZ0xszfZ4yxdTUNyChQQb7JLaH4E8pAbl7ulg0RoJ4ChNWtOqMJC/N3+qcQ=="],
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1003.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.18", "@aws-sdk/credential-provider-node": "^3.972.17", "@aws-sdk/eventstream-handler-node": "^3.972.10", "@aws-sdk/middleware-eventstream": "^3.972.7", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.18", "@aws-sdk/middleware-websocket": "^3.972.12", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/token-providers": "3.1003.0", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.3", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.8", "@smithy/eventstream-serde-browser": "^4.2.11", "@smithy/eventstream-serde-config-resolver": "^4.3.11", "@smithy/eventstream-serde-node": "^4.2.11", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.22", "@smithy/middleware-retry": "^4.4.39", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.38", "@smithy/util-defaults-mode-node": "^4.2.41", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-b39kYrFC3dGFQ7S5UiHKD8aGCFr0/k+QXDzqnT8N2zi8JILEvdxBhMWNqCIpZAbCCK2Jp9S8jK5/Vh0TfLUIPQ=="],
"@aws-sdk/core": ["@aws-sdk/core@3.973.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/xml-builder": "^3.972.9", "@smithy/core": "^3.23.7", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w=="],
"@aws-sdk/core": ["@aws-sdk/core@3.973.18", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.8", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-GUIlegfcK2LO1J2Y98sCJy63rQSiLiDOgVw7HiHPRqfI2vb3XozTVqemwO0VSGXp54ngCnAQz0Lf0YPCBINNxA=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RhHQG1lhkWHL4tK1C/KDjaOeis+9U0tAMnWDiwiSVQZMC7CsST9Xin+sK89XywJ5g/tyABtb7TvFePJ4Te5XSQ=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HrdtnadvTGAQUr18sPzGlE5El3ICphnH6SU7UQOMOWFgRKbTRNN8msTxM4emzguUso9CzaHU2xy5ctSrmK5YNA=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/node-http-handler": "^4.4.13", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" } }, "sha512-b/bDL76p51+yQ+0O9ZDH5nw/ioE0sRYkjwjOwFWAWZXo6it2kQZUOXhVpjohx3ldKyUxt/SwAivjUu1Nr/PWlQ=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/types": "^3.973.5", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/credential-provider-env": "^3.972.15", "@aws-sdk/credential-provider-http": "^3.972.17", "@aws-sdk/credential-provider-login": "^3.972.15", "@aws-sdk/credential-provider-process": "^3.972.15", "@aws-sdk/credential-provider-sso": "^3.972.15", "@aws-sdk/credential-provider-web-identity": "^3.972.15", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-qWnM+wB8MmU2kKY7f4KowKjOjkwRosaFxrtseEEIefwoXn1SjN+CbHzXBVdTAQxxkbBiqhPgJ/WHiPtES4grRQ=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/credential-provider-env": "^3.972.16", "@aws-sdk/credential-provider-http": "^3.972.18", "@aws-sdk/credential-provider-login": "^3.972.16", "@aws-sdk/credential-provider-process": "^3.972.16", "@aws-sdk/credential-provider-sso": "^3.972.16", "@aws-sdk/credential-provider-web-identity": "^3.972.16", "@aws-sdk/nested-clients": "^3.996.6", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hzAnzNXKV0A4knFRWGu2NCt72P4WWxpEGnOc6H3DptUjC4oX3hGw846oN76M1rTHAOwDdbhjU0GAOWR4OUfTZg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x92FJy34/95wgu+qOGD8SHcgh1hZ9Qx2uFtQEGn4m9Ljou8ICIv3Ybq5yxdB7A60S8ZGCQB0mIopmjJwiLbh5g=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/nested-clients": "^3.996.6", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-VI0kXTlr0o1FTay+Jvx6AKqx5ECBgp7X4VevGBEbuXdCXnNp7SPU0KvjsOLVhIz3OoPK4/lTXphk43t0IVk65w=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.16", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.15", "@aws-sdk/credential-provider-http": "^3.972.17", "@aws-sdk/credential-provider-ini": "^3.972.15", "@aws-sdk/credential-provider-process": "^3.972.15", "@aws-sdk/credential-provider-sso": "^3.972.15", "@aws-sdk/credential-provider-web-identity": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-7mlt14Ee4rPFAFUVgpWE7+0CBhetJJyzVFqfIsMp7sgyOSm9Y/+qHZOWAuK5I4JNc+Y5PltvJ9kssTzRo92iXQ=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.17", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.16", "@aws-sdk/credential-provider-http": "^3.972.18", "@aws-sdk/credential-provider-ini": "^3.972.16", "@aws-sdk/credential-provider-process": "^3.972.16", "@aws-sdk/credential-provider-sso": "^3.972.16", "@aws-sdk/credential-provider-web-identity": "^3.972.16", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-98MAcQ2Dk7zkvgwZ5f6fLX2lTyptC3gTSDx4EpvTdJWET8qs9lBPYggoYx7GmKp/5uk0OwVl0hxIDZsDNS/Y9g=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-PrH3iTeD18y/8uJvQD2s/T87BTGhsdS/1KZU7ReWHXsplBwvCqi7AbnnNbML1pFlQwRWCE2RdSZFWDVId3CvkA=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/token-providers": "3.1002.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-M/+LBHTPKZxxXckM6m4dnJeR+jlm9NynH9b2YDswN4Zj2St05SK/crdL3Wy3WfJTZootnnhm3oTh87Usl7PS7w=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/nested-clients": "^3.996.6", "@aws-sdk/token-providers": "3.1003.0", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-b9of7tQgERxgcEcwAFWvRe84ivw+Kw6b3jVuz/6LQzonkomiY5UoWfprkbjc8FSCQ2VjDqKTvIRA9F0KSQ025w=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QTH6k93v+UOfFam/ado8zc71tH+enTVyuvLy9uEWXX1x894dN5ovtf/MdBDgFwq3g6c9mbtgVJ4B+yBqDtXvdA=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.16", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/nested-clients": "^3.996.6", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-PaOH5jFoPQX4WkqpKzKh9cM7rieKtbgEGqrZ+ybGmotJhcvhI/xl69yCwMbHGnpQJJmHZIX9q2zaPB7HTBn/4w=="],
"@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/eventstream-codec": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA=="],
"@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/eventstream-codec": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA=="],
"@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw=="],
"@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@smithy/core": "^3.23.7", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.8", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-KcqQDs/7WtoEnp52+879f8/i1XAJkgka5i4arOtOCPR10o4wWo3VRecDI9Gxoh6oghmLCnIiOSKyRcXI/50E+w=="],
"@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-format-url": "^3.972.6", "@smithy/eventstream-codec": "^4.2.10", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-cWf+8iUUnitgFuUu/ryK2uVfx7f5ezdhGwsjLLEEC1Nk716Ld2Hw4LA8iipyVcQI3EarvK6ExY2dSBET/0PYng=="],
"@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-format-url": "^3.972.7", "@smithy/eventstream-codec": "^4.2.11", "@smithy/eventstream-serde-browser": "^4.2.11", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.5", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.6", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.18", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.18", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.3", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.8", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.22", "@smithy/middleware-retry": "^4.4.39", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.38", "@smithy/util-defaults-mode-node": "^4.2.41", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-blNJ3ugn4gCQ9ZSZi/firzKCvVl5LvPFVxv24LprENeWI4R8UApG006UQkF4SkmLygKq2BQXRad2/anQ13Te4Q=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/config-resolver": "^4.4.9", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1002.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x972uKOydFn4Rb0PZJzLdNW59rH0KWC78Q2JbQzZpGlGt0DxjYdDRwBG6F42B1MyaEwHGqO/tkGc4r3/PRFfMw=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1003.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.18", "@aws-sdk/nested-clients": "^3.996.6", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-SOyyWNdT7njKRwtZ1JhwHlH1csv6Pkgf305X96/OIfnhq1pU/EjmT6W6por57rVrjrKuHBuEIXgpWv8OgoMHpg=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" } }, "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="],
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw=="],
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.2", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.3", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.18", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-8s2cQmTUOwcBlIJyI9PAZNnnnF+cGtdhHc1yzMMsSD/GR/Hxj7m0IGUE92CslXXb8/p5Q76iqOCjN1GFwyf+1A=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.9", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
@@ -263,23 +263,23 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="],
"@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.11.0", "", {}, "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ=="],
@@ -455,7 +455,7 @@
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@@ -467,21 +467,21 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260304.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260304.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260304.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260304.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260304.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260304.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260304.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260304.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-Xj0ZeHEy+yJ/bIg6psPwl0POvBf1j5u7IZAXsUqgvgWbMIvdM9JOGmhpifcj6j28LcXM6GTvXUoXwlatxJ73Qg=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260306.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260306.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260306.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260306.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260306.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260306.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260306.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260306.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-4m7cOjtKu+iLazWW5MuJuI2ZZMkQkS42+GxN6FVdja1nL0t47l1wpaTnzUa1Ny9Xa0opIJ7psPAMBKYAPKbCKA=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260304.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TnTUxYt+dShRSoeOldx7VlKoEG+bvPHnyPEBImlNc7c3WP0AHYyNHrNg6EbLbzkOorARtd06J3Vk+XYzkrRzZg=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260306.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4vuh4VlPydMS/nymDzjJIKDk3dntnEEB5UzyJV9mM4kxF5+geFgJih1DTtZS3qVafhHLB3e4l8omtvGftMnb8g=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260304.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1nwXX1zbyYI3sDKdaR8NsBdM7LmE0J6OzVtlWgEJ/8YR7oC2/HY6/SfShF3DHHcEOHOFxRLbkJ9zVTJJspWLCw=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260306.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-qxYfv0aM4KCZPEe584KIjT5sO4uR+xdyuQXX5tXbnH1UoksIz7bvJ9KUgRloS/q/ww0f8UjPS2+27LnRA4y7ig=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260304.1", "", { "os": "linux", "cpu": "arm" }, "sha512-TXZClCJVteK2f9gcI+I7o1Sxgq3qdMtraXOP9GZF8o0sKCLdDWENN8uORfZSeQv2qOJohcKvrrEz6LLSSngvEg=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260306.1", "", { "os": "linux", "cpu": "arm" }, "sha512-8gRAFx0ExDWHOmphl8mzBrSoGWnLWDU4VpxkPRsWqaJpHVbjr9Yk2QkuJNIaDmF6q44eJmW/huSiObmHTbZ1UQ=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260304.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cw+xqroXtsk/yVTKbelcPWMd6oZdET9kNWmigyc189KWwzOu2eq2EPXPQsrhEigq8O3j0xW0z3q2oqG+smOiXg=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260306.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-8G0BKvTkE+eKX1tSnyKeDaf3bWPWY7OI77SMipagCAyYi06v4gxx+IVE3Px7W7kLX2Wqp1MjWDXu2N76wfJtXQ=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260304.1", "", { "os": "linux", "cpu": "x64" }, "sha512-EXufnN4PG0HYBHYbHXQXXRXtaQKuKBT3e6nxPhKnwpBBgy2MgWDIxzroTLvI9+SllhbJQzHNZOWiB+SU+KdCNw=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260306.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rsJV3Z9J/zYCEtcqvm+WfLAml3i1OAyMEUn0hja7i8C0kzE+tXKXzsJ0+I1TrSU5O7hHvqlLTvueBoCoM4aL4g=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260304.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Be9yyDDbT/PEdNlhG+NXT47fwuiIeN0+/9BkeRKkiLgzY8DqQIC9w5FRWmwAJ+9PVa2sKr5cjD1SpJDHGrPIrA=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260306.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-US1WsIu9IukaFzM+w8wt0fIAkmk2WtxeVuk8nkbrnH9S3ax39r0J4ikMNZSXEJE0VMxhXJoymzfWxhj3s9yW/Q=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260304.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lg/w+rZ9NIUoqSsk2TbtDsqyD9nW0/rhTMYd14RFP7vuNijLrTbl7GPiMhFtMxaqCSOFapwbql7/3lU4BKHB6g=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260306.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MlneT0RWS9Zdb8XoWvHsUgmnMJu6K3S0BXRu5ZgUYjcbQKlkz+Z87aUB8eX8qnDFd9csJcMp3+ZrgQ/LKVGP1g=="],
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
@@ -821,7 +821,7 @@
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"openai": ["openai@6.25.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww=="],
"openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
+8
View File
@@ -1,10 +1,18 @@
# Changelog
## [Unreleased]
### Added
- Exported `ThinkingLevel` selector constants and types for configuring agent reasoning behavior
- Added `inherit` thinking level option to defer reasoning configuration to higher-level selectors
- Added `serviceTier` option to configure service tier for agent requests
### Changed
- Changed `thinkingLevel` from required string to optional `Effort` type, allowing undefined state
- Updated `setThinkingLevel()` method to accept `Effort | undefined` instead of `ThinkingLevel` string
## [13.4.0] - 2026-03-01
### Added
+4 -4
View File
@@ -5,6 +5,7 @@ import {
type AssistantMessage,
type CursorExecHandlers,
type CursorToolResultHandler,
type Effort,
getBundledModel,
type ImageContent,
type Message,
@@ -14,7 +15,6 @@ import {
streamSimple,
type TextContent,
type ThinkingBudgets,
type ThinkingLevel,
type ToolChoice,
type ToolResultMessage,
} from "@oh-my-pi/pi-ai";
@@ -175,7 +175,7 @@ export class Agent {
#state: AgentState = {
systemPrompt: "",
model: getBundledModel("google", "gemini-2.5-flash-lite-preview-06-17"),
thinkingLevel: "off",
thinkingLevel: undefined,
tools: [],
messages: [],
isStreaming: false,
@@ -416,7 +416,7 @@ export class Agent {
this.#state.model = m;
}
setThinkingLevel(l: ThinkingLevel) {
setThinkingLevel(l: Effort | undefined) {
this.#state.thinkingLevel = l;
}
@@ -669,7 +669,7 @@ export class Agent {
// Clear Cursor tool result buffer at start of each run
this.#cursorToolResultBuffer = [];
const reasoning = this.#state.thinkingLevel === "off" ? undefined : this.#state.thinkingLevel;
const reasoning = this.#state.thinkingLevel;
const context: AgentContext = {
systemPrompt: this.#state.systemPrompt,
+2
View File
@@ -4,5 +4,7 @@ export * from "./agent";
export * from "./agent-loop";
// Proxy utilities
export * from "./proxy";
// Thinking selectors
export * from "./thinking";
// Types
export * from "./types";
+19
View File
@@ -0,0 +1,19 @@
import { Effort } from "@oh-my-pi/pi-ai";
/**
* Agent-local thinking selector.
*
* `off` disables reasoning, while `inherit` defers to a higher-level selector.
*/
export const ThinkingLevel = {
Inherit: "inherit",
Off: "off",
Minimal: Effort.Minimal,
Low: Effort.Low,
Medium: Effort.Medium,
High: Effort.High,
XHigh: Effort.XHigh,
} as const;
export type ThinkingLevel = (typeof ThinkingLevel)[keyof typeof ThinkingLevel];
export type ResolvedThinkingLevel = Exclude<ThinkingLevel, "inherit">;
+2 -2
View File
@@ -1,13 +1,13 @@
import type {
AssistantMessageEvent,
AssistantMessageEventStream,
Effort,
ImageContent,
Message,
Model,
SimpleStreamOptions,
streamSimple,
TextContent,
ThinkingLevel,
Tool,
ToolChoice,
ToolResultMessage,
@@ -171,7 +171,7 @@ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessag
export interface AgentState {
systemPrompt: string;
model: Model;
thinkingLevel: ThinkingLevel;
thinkingLevel?: Effort;
tools: AgentTool<any>[];
messages: AgentMessage[]; // Can include attachments + custom message types
isStreaming: boolean;
+6 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { Agent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { type AssistantMessage, getBundledModel, type ThinkingBudgets, type Usage } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
@@ -39,7 +39,7 @@ describe("Agent", () => {
expect(agent.state).toBeDefined();
expect(agent.state.systemPrompt).toBe("");
expect(agent.state.model).toBeDefined();
expect(agent.state.thinkingLevel).toBe("off");
expect(agent.state.thinkingLevel).toBeUndefined();
expect(agent.state.tools).toEqual([]);
expect(agent.state.messages).toEqual([]);
expect(agent.state.isStreaming).toBe(false);
@@ -54,13 +54,13 @@ describe("Agent", () => {
initialState: {
systemPrompt: "You are a helpful assistant.",
model: customModel,
thinkingLevel: "low",
thinkingLevel: ThinkingLevel.Low,
},
});
expect(agent.state.systemPrompt).toBe("You are a helpful assistant.");
expect(agent.state.model).toBe(customModel);
expect(agent.state.thinkingLevel).toBe("low");
expect(agent.state.thinkingLevel).toBe(ThinkingLevel.Low);
});
it("should subscribe to events", () => {
@@ -98,8 +98,8 @@ describe("Agent", () => {
expect(agent.state.model).toBe(newModel);
// Test setThinkingLevel
agent.setThinkingLevel("high");
expect(agent.state.thinkingLevel).toBe("high");
agent.setThinkingLevel(ThinkingLevel.High);
expect(agent.state.thinkingLevel).toBe(ThinkingLevel.High);
// Test setTools
const tools = [{ name: "test", description: "test tool" } as any];
+44 -6
View File
@@ -1,8 +1,26 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed `reasoning` parameter from `ThinkingLevel | undefined` to `Effort | undefined` in `SimpleStreamOptions`; 'off' is no longer valid (omit the field instead)
- Removed `supportsXhigh()` function; check `model.thinking?.maxLevel` instead
- Removed `ThinkingLevel` and `ThinkingEffort` types; use `Effort` enum
- Removed `getAvailableThinkingLevels()` and `getAvailableThinkingEfforts()` functions
- Changed `transformRequestBody()` signature to require `Model` parameter as second argument for effort validation
- Removed `thinking.ts` module export; import from `model-thinking.ts` instead
### Added
- Added `ThinkingConfig` interface to models for canonical thinking transport metadata with min/max effort levels and provider-specific mode
- Added `thinking` field to `Model` type containing per-model thinking capabilities used to clamp and map user-facing effort levels
- Added `Effort` enum (minimal, low, medium, high, xhigh) as canonical user-facing thinking levels replacing `ThinkingLevel`
- Added `enrichModelThinking()` function to automatically populate thinking metadata on models based on their capabilities
- Added `mapEffortToAnthropicAdaptiveEffort()` function to map user effort levels to Anthropic adaptive thinking effort
- Added `mapEffortToGoogleThinkingLevel()` function to map user effort levels to Google thinking levels
- Added `requireSupportedEffort()` function to validate and clamp effort levels per model, throwing errors for unsupported combinations
- Added `clampThinkingLevelForModel()` function to clamp thinking levels to model-supported range
- Added `applyGeneratedModelPolicies()` and `linkSparkPromotionTargets()` exports from model-thinking module
- Added `serviceTier` option to control OpenAI processing priority and cost (auto, default, flex, scale, priority)
- Added `providerPayload` field to messages and responses for reconstructing transport-native history
- Added Gemini usage provider for tracking quota and tier information
@@ -11,6 +29,14 @@
### Changed
- Changed `reasoning` parameter type from `ThinkingLevel` to `Effort` in `SimpleStreamOptions`, removing 'off' value (callers should omit the field instead)
- Changed thinking configuration to use model-specific metadata instead of hardcoded provider logic for effort mapping
- Changed OpenAI Codex request transformer to accept `Model` parameter for effort validation instead of string model ID
- Changed Anthropic provider to use model thinking metadata for determining adaptive thinking support instead of model ID pattern matching
- Changed Google Vertex and Google providers to use shorter variable names for thinking config construction
- Moved thinking-related utilities from `thinking.ts` to new `model-thinking.ts` module with expanded functionality
- Moved model policy functions from `provider-models/model-policies.ts` to `model-thinking.ts`
- Moved `googleGeminiCliUsageProvider` from `providers/google-gemini-cli-usage.ts` to `usage/gemini.ts`
- Changed default OpenAI model from gpt-5.1-codex to gpt-5.4 across all providers
- Changed `UsageFetchContext` to remove cache and now() dependencies—usage fetchers now use Date.now() directly
- Removed `resetInMs` field from usage windows; consumers should calculate from `resetsAt` timestamp
@@ -19,6 +45,13 @@
### Removed
- Removed `thinking.ts` module; use `model-thinking.ts` instead
- Removed `provider-models/model-policies.ts` module; functionality moved to `model-thinking.ts`
- Removed `supportsXhigh()` function from models.ts; use model.thinking metadata instead
- Removed `ThinkingLevel` and `ThinkingEffort` types; use `Effort` enum instead
- Removed `getAvailableThinkingLevels()` and `getAvailableThinkingEfforts()` functions
- Removed `model-policies` export from `provider-models/index.ts`
- Removed hardcoded thinking level clamping logic from OpenAI Codex request transformer; now uses model metadata
- Removed `UsageCache` and `UsageCacheEntry` interfaces—caching is now handled internally by AuthStorage
- Removed `google-gemini-cli-usage` export; use new `gemini` usage provider instead
- Removed `resetInMs` computation from all usage providers
@@ -26,6 +59,9 @@
### Fixed
- Fixed OpenAI Codex to reject unsupported effort levels instead of silently clamping them, providing clear error messages about supported efforts
- Fixed model cache normalization to properly apply thinking enrichment when loading cached models
- Fixed dynamic model merging to apply thinking enrichment to merged model results
- Fixed OpenAI Codex streaming to properly include service_tier in SSE payloads
- Fixed type safety in OpenAI responses by removing unsafe type casts on image content blocks
- Fixed credential purging to respect disabled credentials when deduplicating by email
@@ -47,31 +83,33 @@
- Fixed Unicode normalization to consistently apply `toWellFormed()` to all text content, including thinking blocks, ensuring proper handling of malformed UTF-16 sequences
## [13.9.1] - 2026-03-05
### Breaking Changes
- Removed `THINKING_LEVELS`, `ALL_THINKING_LEVELS`, `ALL_THINKING_MODES`, `THINKING_MODE_DESCRIPTIONS`, and `THINKING_MODE_LABELS` exports
- Renamed `formatThinking()` to `getThinkingMetadata()` with changed return type from string to `ThinkingMetadata` object
- Renamed `getAvailableThinkingLevel()` to `getAvailableThinkingLevels()` and added default parameter
- Renamed `getAvailableThinkingEffort()` to `getAvailableThinkingEfforts()` and added default parameter
- Renamed `getAvailableEffort()` to `getAvailableEfforts()` and added default parameter
### Added
- Added `ThinkingMetadata` type to provide structured access to thinking mode information (value, label, description)
## [13.9.0] - 2026-03-05
### Added
- Exported new thinking module with `ThinkingEffort`, `ThinkingLevel`, and `ThinkingMode` types for managing reasoning effort levels
- Added `getAvailableThinkingEffort()` function to determine supported thinking effort levels based on model capabilities
- Added `parseThinkingEffort()`, `parseThinkingLevel()`, and `parseThinkingMode()` functions for parsing thinking configuration strings
- Exported new thinking module with `Effort`, `ThinkingLevel`, and `ThinkingMode` types for managing reasoning effort levels
- Added `getAvailableEffort()` function to determine supported thinking effort levels based on model capabilities
- Added `parseEffort()`, `parseThinkingLevel()`, and `parseThinkingMode()` functions for parsing thinking configuration strings
- Added `THINKING_LEVELS`, `ALL_THINKING_LEVELS`, and `ALL_THINKING_MODES` constants for iterating over available thinking options
- Added `THINKING_MODE_DESCRIPTIONS` and `THINKING_MODE_LABELS` for displaying thinking modes in user interfaces
- Added `formatThinking()` function to format thinking modes as compact display labels
### Changed
- Refactored thinking level handling to distinguish between `ThinkingEffort` (provider-level, no "off") and `ThinkingLevel` (user-facing, includes "off")
- Updated `ThinkingBudgets` type to use `ThinkingEffort` instead of `ThinkingLevel` for more precise token budget configuration
- Refactored thinking level handling to distinguish between `Effort` (provider-level, no "off") and `ThinkingLevel` (user-facing, includes "off")
- Updated `ThinkingBudgets` type to use `Effort` instead of `ThinkingLevel` for more precise token budget configuration
- Improved reasoning option handling to explicitly support "off" value for disabling reasoning across all providers
- Simplified thinking effort mapping logic by centralizing provider-specific clamping behavior
+5 -5
View File
@@ -12,6 +12,11 @@ import * as path from "node:path";
import { $env } from "@oh-my-pi/pi-utils";
import { AuthCredentialStore } from "../src/auth-storage";
import { createModelManager } from "../src/model-manager";
import {
applyGeneratedModelPolicies,
CLOUDFLARE_FALLBACK_MODEL,
linkSparkPromotionTargets,
} from "../src/model-thinking";
import prevModelsJson from "../src/models.json" with { type: "json" };
import {
allowsUnauthenticatedCatalogDiscovery,
@@ -20,11 +25,6 @@ import {
isCatalogDescriptor,
PROVIDER_DESCRIPTORS,
} from "../src/provider-models/descriptors";
import {
applyGeneratedModelPolicies,
CLOUDFLARE_FALLBACK_MODEL,
linkSparkPromotionTargets,
} from "../src/provider-models/model-policies";
import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "../src/provider-models/openai-compat";
import { getGitLabDuoModels } from "../src/providers/gitlab-duo";
import { JWT_CLAIM_PATH } from "../src/providers/openai-codex/constants";
+1 -1
View File
@@ -11,7 +11,6 @@ import { Database, type Statement } from "bun:sqlite";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { getAgentDir, logger } from "@oh-my-pi/pi-utils";
import { googleGeminiCliUsageProvider } from "./providers/google-gemini-cli-usage";
import { getEnvApiKey } from "./stream";
import type { Provider } from "./types";
import type {
@@ -23,6 +22,7 @@ import type {
UsageReport,
} from "./usage";
import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
import { googleGeminiCliUsageProvider } from "./usage/gemini";
import { githubCopilotUsageProvider } from "./usage/github-copilot";
import { antigravityUsageProvider } from "./usage/google-antigravity";
import { kimiUsageProvider } from "./usage/kimi";
+1 -1
View File
@@ -4,6 +4,7 @@ export * from "./api-registry";
export * from "./auth-storage";
export * from "./model-cache";
export * from "./model-manager";
export * from "./model-thinking";
export * from "./models";
export * from "./provider-details";
export * from "./provider-models";
@@ -20,7 +21,6 @@ export * from "./providers/openai-responses";
export * from "./providers/synthetic";
export * from "./rate-limit-utils";
export * from "./stream";
export * from "./thinking";
export * from "./types";
export * from "./usage";
export * from "./usage/claude";
+5 -4
View File
@@ -1,4 +1,5 @@
import { readModelCache, writeModelCache } from "./model-cache";
import { enrichModelThinking } from "./model-thinking";
import { type GeneratedProvider, getBundledModels } from "./models";
import type { Api, Model, Provider } from "./types";
import { isRecord } from "./utils";
@@ -108,7 +109,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
const shouldUseFreshCacheAsAuthoritative =
strategy === "online-if-uncached" && (cache?.fresh ?? false) && hasAuthoritativeCache;
const dynamicFetchSucceeded = fetchedDynamicModels !== null;
const cacheModels = dynamicFetchSucceeded ? [] : (cache?.models ?? []);
const cacheModels = dynamicFetchSucceeded ? [] : normalizeModelList<TApi>(cache?.models ?? []);
const dynamicModels = fetchedDynamicModels ?? [];
const mergedWithoutDynamic = mergeModelSources(staticModels, modelsDevModels, cacheModels);
const models = mergeDynamicModels(mergedWithoutDynamic, dynamicModels);
@@ -223,7 +224,7 @@ function mergeDynamicModels<TApi extends Api>(
function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamicModel: Model<TApi>): Model<TApi> {
const supportsImage = existingModel.input.includes("image") || dynamicModel.input.includes("image");
return {
return enrichModelThinking({
...existingModel,
...dynamicModel,
name: preferDiscoveryName(dynamicModel.name, existingModel.name, dynamicModel.id),
@@ -240,7 +241,7 @@ function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamic
headers: dynamicModel.headers ? { ...existingModel.headers, ...dynamicModel.headers } : existingModel.headers,
compat: dynamicModel.compat ?? existingModel.compat,
contextPromotionTarget: dynamicModel.contextPromotionTarget ?? existingModel.contextPromotionTarget,
};
});
}
function preferDiscoveryCost(discoveryCost: number, fallbackCost: number): number {
@@ -278,7 +279,7 @@ function normalizeModelList<TApi extends Api>(value: unknown): Model<TApi>[] {
const models: Model<TApi>[] = [];
for (const item of value) {
if (isModelLike(item)) {
models.push(item as Model<TApi>);
models.push(enrichModelThinking(item as Model<TApi>));
}
}
return models;
+526
View File
@@ -0,0 +1,526 @@
import type { Api, Model as ApiModel, ThinkingConfig } from "./types";
/** User-facing thinking levels, ordered least to most intensive. */
export const enum Effort {
Minimal = "minimal",
Low = "low",
Medium = "medium",
High = "high",
XHigh = "xhigh",
}
export const THINKING_EFFORTS: readonly Effort[] = [
Effort.Minimal,
Effort.Low,
Effort.Medium,
Effort.High,
Effort.XHigh,
];
const DEFAULT_REASONING_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
const DEFAULT_REASONING_EFFORTS_WITH_XHIGH: readonly Effort[] = [
Effort.Minimal,
Effort.Low,
Effort.Medium,
Effort.High,
Effort.XHigh,
];
const GEMINI_3_PRO_EFFORTS: readonly Effort[] = [Effort.Low, Effort.High];
const GEMINI_3_FLASH_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
const GPT_5_2_PLUS_EFFORTS: readonly Effort[] = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh];
const GPT_5_1_CODEX_MINI_EFFORTS: readonly Effort[] = [Effort.Medium, Effort.High];
const CLOUDFLARE_AI_GATEWAY_BASE_URL = "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic";
type SemVer = {
major: number;
minor: number;
patch: number;
};
type GeminiKind = "pro" | "flash";
type AnthropicKind = "opus" | "sonnet";
type OpenAIVariant = "base" | "codex" | "codex-max" | "codex-mini" | "codex-spark" | "max" | "nano";
interface GeminiModel {
family: "gemini";
kind: GeminiKind;
version: SemVer;
}
interface AnthropicModel {
family: "anthropic";
kind: AnthropicKind;
version: SemVer;
}
interface OpenAIModel {
family: "openai";
variant: OpenAIVariant;
version: SemVer;
}
interface UnknownModel {
family: "unknown";
id: string;
}
type ParsedModel = GeminiModel | AnthropicModel | OpenAIModel | UnknownModel;
/**
* Static fallback model injected when Cloudflare AI Gateway discovery
* returns no results. Ensures the provider always has at least one usable
* model entry in the catalog.
*/
export const CLOUDFLARE_FALLBACK_MODEL: ApiModel<"anthropic-messages"> = {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
api: "anthropic-messages",
provider: "cloudflare-ai-gateway",
baseUrl: CLOUDFLARE_AI_GATEWAY_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
},
contextWindow: 200000,
maxTokens: 64000,
};
/**
* Returns a copy of the model with canonical thinking metadata attached.
*
* This helper belongs to catalog enrichment only. Runtime consumers should
* trust `model.thinking` and avoid inferring capabilities on demand.
*/
export function enrichModelThinking<TApi extends Api>(model: ApiModel<TApi>): ApiModel<TApi> {
const normalizedThinking = normalizeThinkingConfig(model.thinking);
if (!model.reasoning) {
return normalizedThinking === undefined && model.thinking === undefined
? model
: { ...model, thinking: undefined };
}
const thinking = normalizedThinking ?? inferModelThinking(model);
if (thinkingsEqual(normalizedThinking, thinking)) {
return model;
}
return { ...model, thinking };
}
/**
* Returns a copy of the model with thinking metadata recomputed from the
* canonical rules, replacing any existing `thinking`.
*/
export function refreshModelThinking<TApi extends Api>(model: ApiModel<TApi>): ApiModel<TApi> {
if (!model.reasoning) {
const normalizedThinking = normalizeThinkingConfig(model.thinking);
return normalizedThinking === undefined && model.thinking === undefined
? model
: { ...model, thinking: undefined };
}
return { ...model, thinking: inferModelThinking(model) };
}
/**
* Apply upstream metadata corrections to a mutable array of models.
*
* Each model is first normalized through `refreshModelThinking()` so generated
* catalogs keep canonical thinking metadata and policy fixes in one pass.
*/
export function applyGeneratedModelPolicies(models: ApiModel<Api>[]): void {
for (let index = 0; index < models.length; index++) {
const model = refreshModelThinking(models[index]!);
applyGeneratedModelPolicy(model);
models[index] = model;
}
}
/**
* Link `-spark` model variants to their base models for context promotion.
*
* When a spark model's context is exhausted, the agent can promote to the
* corresponding full model. This sets `contextPromotionTarget` on each
* spark variant that has a matching base model.
*/
export function linkSparkPromotionTargets(models: ApiModel<Api>[]): void {
for (const candidate of models) {
const parsedCandidate = parseKnownModel(candidate.id);
if (parsedCandidate.family !== "openai" || parsedCandidate.variant !== "codex-spark") continue;
const baseId = candidate.id.slice(0, -"-spark".length);
const fallback = models.find(
model => model.provider === candidate.provider && model.api === candidate.api && model.id === baseId,
);
if (!fallback) continue;
candidate.contextPromotionTarget = `${fallback.provider}/${fallback.id}`;
}
}
/**
* Returns supported thinking efforts from canonical model rules constrained by
* explicit model metadata.
*
* @throws Error when a reasoning-capable model is missing thinking metadata
*/
export function getSupportedEfforts<TApi extends Api>(model: ApiModel<TApi>): readonly Effort[] {
if (!model.reasoning) {
return [];
}
if (!model.thinking) {
throw new Error(`Model ${model.provider}/${model.id} is missing thinking metadata`);
}
const configuredEfforts = expandEffortRange(model.thinking);
const parsedModel = parseKnownModel(model.id);
if (parsedModel.family === "unknown") {
return configuredEfforts;
}
return intersectEfforts(configuredEfforts, inferSupportedEfforts(parsedModel, model));
}
/**
* Clamps a requested thinking level against explicit model metadata.
*
* Non-reasoning models always resolve to `undefined`.
*/
export function clampThinkingLevelForModel<TApi extends Api>(
model: ApiModel<TApi> | undefined,
requested: Effort | undefined,
): Effort | undefined {
if (!model) {
return requested;
}
if (!model.reasoning || requested === undefined) {
return undefined;
}
const levels = getSupportedEfforts(model);
if (levels.includes(requested)) {
return requested;
}
const requestedIndex = THINKING_EFFORTS.indexOf(requested);
if (requestedIndex === -1) {
return undefined;
}
let clamped: Effort | undefined;
for (const effort of levels) {
if (THINKING_EFFORTS.indexOf(effort) > requestedIndex) {
break;
}
clamped = effort;
}
return clamped ?? levels[0];
}
export function requireSupportedEffort<TApi extends Api>(model: ApiModel<TApi>, effort: Effort): Effort {
if (!model.reasoning) {
throw new Error(`Model ${model.provider}/${model.id} does not support thinking`);
}
const levels = getSupportedEfforts(model);
if (!levels.includes(effort)) {
throw new Error(
`Thinking effort ${effort} is not supported by ${model.provider}/${model.id}. Supported efforts: ${levels.join(", ")}`,
);
}
return effort;
}
/** Maps a normalized thinking effort to Google's `thinkingLevel` enum values. */
export function mapEffortToGoogleThinkingLevel<TApi extends Api>(
model: ApiModel<TApi>,
effort: Effort,
): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" {
switch (requireSupportedEffort(model, effort)) {
case Effort.Minimal:
return "MINIMAL";
case Effort.Low:
return "LOW";
case Effort.Medium:
return "MEDIUM";
case Effort.High:
case Effort.XHigh:
return "HIGH";
}
}
/** Maps a normalized thinking effort to Anthropic adaptive effort values. */
export function mapEffortToAnthropicAdaptiveEffort<TApi extends Api>(
model: ApiModel<TApi>,
effort: Effort,
): "low" | "medium" | "high" | "max" {
switch (requireSupportedEffort(model, effort)) {
case Effort.Minimal:
case Effort.Low:
return "low";
case Effort.Medium:
return "medium";
case Effort.High:
return "high";
case Effort.XHigh:
return "max";
}
}
function applyGeneratedModelPolicy(model: ApiModel<Api>): void {
const parsedModel = parseKnownModel(model.id);
if (parsedModel.family === "anthropic") {
applyAnthropicCatalogPolicy(model, parsedModel);
}
if (parsedModel.family === "openai") {
applyOpenAICatalogPolicy(model, parsedModel);
}
}
function applyAnthropicCatalogPolicy(model: ApiModel<Api>, parsedModel: AnthropicModel): void {
// Claude Opus 4.5: models.dev reports 3x the correct cache pricing.
if (model.provider === "anthropic" && parsedModel.kind === "opus" && semverEqual(parsedModel.version, "4.5")) {
model.cost.cacheRead = 0.5;
model.cost.cacheWrite = 6.25;
}
// Bedrock Opus 4.6: upstream cache pricing is incorrect.
if (model.provider === "amazon-bedrock" && parsedModel.kind === "opus" && semverEqual(parsedModel.version, "4.6")) {
model.cost.cacheRead = 0.5;
model.cost.cacheWrite = 6.25;
}
// Opus 4.6 / Sonnet 4.6: 1M context is beta; clamp to 200K.
if (semverEqual(parsedModel.version, "4.6")) {
model.contextWindow = 200000;
}
// OpenCode variants: Claude Sonnet 4/4.5 listed with 1M context, actual limit is 200K.
if (
(model.provider === "opencode-zen" || model.provider === "opencode-go") &&
parsedModel.kind === "sonnet" &&
(semverEqual(parsedModel.version, "4.0") || semverEqual(parsedModel.version, "4.5"))
) {
model.contextWindow = 200000;
}
}
function applyOpenAICatalogPolicy(model: ApiModel<Api>, parsedModel: OpenAIModel): void {
// Codex models: 400K figure includes output budget; input window is 272K.
if (parsedModel.variant.startsWith("codex") && parsedModel.variant !== "codex-spark") {
model.contextWindow = 272000;
}
}
function inferModelThinking<TApi extends Api>(model: ApiModel<TApi>): ThinkingConfig {
const parsedModel = parseKnownModel(model.id);
const efforts = inferSupportedEfforts(parsedModel, model);
const minLevel = efforts[0];
const maxLevel = efforts.at(-1);
if (!minLevel || !maxLevel) {
throw new Error(`Model ${model.provider}/${model.id} resolved to an empty thinking range`);
}
return {
mode: inferThinkingControlMode(model, parsedModel),
minLevel,
maxLevel,
};
}
function normalizeThinkingConfig(thinking: ThinkingConfig | undefined): ThinkingConfig | undefined {
if (!thinking || expandEffortRange(thinking).length === 0) {
return undefined;
}
return thinking;
}
function thinkingsEqual(left: ThinkingConfig | undefined, right: ThinkingConfig | undefined): boolean {
if (left === right) return true;
if (!left || !right) return false;
return left.mode === right.mode && left.minLevel === right.minLevel && left.maxLevel === right.maxLevel;
}
function expandEffortRange(thinking: ThinkingConfig): readonly Effort[] {
const minIndex = THINKING_EFFORTS.indexOf(thinking.minLevel);
const maxIndex = THINKING_EFFORTS.indexOf(thinking.maxLevel);
if (minIndex === -1 || maxIndex === -1 || minIndex > maxIndex) {
return [];
}
return THINKING_EFFORTS.slice(minIndex, maxIndex + 1);
}
function intersectEfforts(left: readonly Effort[], right: readonly Effort[]): readonly Effort[] {
return left.filter(effort => right.includes(effort));
}
function inferSupportedEfforts<TApi extends Api>(parsedModel: ParsedModel, model: ApiModel<TApi>): readonly Effort[] {
switch (parsedModel.family) {
case "openai":
return inferOpenAISupportedEfforts(parsedModel);
case "gemini":
return inferGeminiSupportedEfforts(parsedModel);
case "anthropic":
return inferAnthropicSupportedEfforts(parsedModel, model);
case "unknown":
return inferFallbackEfforts(model);
}
}
function inferOpenAISupportedEfforts(model: OpenAIModel): readonly Effort[] {
if (model.variant === "codex-mini" && semverEqual(model.version, "5.1")) {
return GPT_5_1_CODEX_MINI_EFFORTS;
}
if (semverGte(model.version, "5.2")) {
return GPT_5_2_PLUS_EFFORTS;
}
return DEFAULT_REASONING_EFFORTS;
}
function inferGeminiSupportedEfforts(model: GeminiModel): readonly Effort[] {
if (!semverGte(model.version, "3.0")) {
return DEFAULT_REASONING_EFFORTS;
}
return model.kind === "pro" ? GEMINI_3_PRO_EFFORTS : GEMINI_3_FLASH_EFFORTS;
}
function inferAnthropicSupportedEfforts<TApi extends Api>(
parsedModel: AnthropicModel,
model: ApiModel<TApi>,
): readonly Effort[] {
if (model.api === "anthropic-messages" && semverGte(parsedModel.version, "4.6")) {
return parsedModel.kind === "opus" ? DEFAULT_REASONING_EFFORTS_WITH_XHIGH : DEFAULT_REASONING_EFFORTS;
}
return inferFallbackEfforts(model);
}
function inferFallbackEfforts<TApi extends Api>(model: ApiModel<TApi>): readonly Effort[] {
if (model.api === "anthropic-messages") {
return DEFAULT_REASONING_EFFORTS_WITH_XHIGH;
}
if (model.api === "bedrock-converse-stream") {
return DEFAULT_REASONING_EFFORTS;
}
return DEFAULT_REASONING_EFFORTS;
}
function inferThinkingControlMode<TApi extends Api>(
model: ApiModel<TApi>,
parsedModel: ParsedModel,
): ThinkingConfig["mode"] {
switch (model.api) {
case "google-generative-ai":
case "google-gemini-cli":
case "google-vertex":
return parsedModel.family === "gemini" &&
semverGte(parsedModel.version, "3.0") &&
parsedModel.version.major === 3
? "google-level"
: "budget";
case "anthropic-messages":
if (parsedModel.family === "anthropic") {
if (semverGte(parsedModel.version, "4.6")) {
return "anthropic-adaptive";
}
if (semverGte(parsedModel.version, "4.5")) {
return "anthropic-budget-effort";
}
}
return "budget";
case "bedrock-converse-stream":
return "budget";
default:
return "effort";
}
}
function parseKnownModel(modelId: string): ParsedModel {
const canonicalId = getCanonicalModelId(modelId);
return (
parseGeminiModel(canonicalId) ??
parseAnthropicModel(canonicalId) ??
parseOpenAIModel(canonicalId) ?? { family: "unknown", id: canonicalId }
);
}
function parseGeminiModel(modelId: string): GeminiModel | null {
const match = /gemini-(\d+(?:\.\d+){0,2})-(pro|flash)\b/.exec(modelId);
if (!match) {
return null;
}
const version = parseSemVer(match[1]);
if (!version) {
return null;
}
return { family: "gemini", kind: match[2] as GeminiKind, version };
}
function parseAnthropicModel(modelId: string): AnthropicModel | null {
const match = /claude-(opus|sonnet)-(\d+(?:[.-]\d+){0,2})\b/.exec(modelId);
if (!match) {
return null;
}
const version = parseSemVer(match[2]);
if (!version) {
return null;
}
return { family: "anthropic", kind: match[1] as AnthropicKind, version };
}
function parseOpenAIModel(modelId: string): OpenAIModel | null {
const match = /gpt-(\d+(?:\.\d+){0,2})(?:-(codex-spark|codex-mini|codex-max|codex|max|nano))?\b/.exec(modelId);
if (!match) {
return null;
}
const version = parseSemVer(match[1]);
if (!version) {
return null;
}
return { family: "openai", variant: (match[2] as OpenAIVariant | undefined) ?? "base", version };
}
function createSemVer(major: number, minor: number, patch = 0): SemVer {
return { major, minor, patch };
}
// extend this table if we need anything more than 9.10
const precomputeTable: Record<string, SemVer> = {};
for (let major = 0; major <= 9; major++) {
for (let minor = 0; minor <= 10; minor++) {
const version = createSemVer(major, minor, 0);
precomputeTable[`${major}.${minor}`] = version;
precomputeTable[`${major}-${minor}`] = version;
}
precomputeTable[`${major}`] = createSemVer(major, 0, 0);
}
function parseSemVer(version: string): SemVer | null {
return precomputeTable[version] ?? null;
}
function semverGte(left: SemVer | string, right: SemVer | string): boolean {
return compareSemVer(left, right) >= 0;
}
function semverEqual(left: SemVer | string, right: SemVer | string): boolean {
return compareSemVer(left, right) === 0;
}
function compareSemVer(left: SemVer | string | null, right: SemVer | string | null): number {
left = typeof left === "string" ? parseSemVer(left) : left;
right = typeof right === "string" ? parseSemVer(right) : right;
if (!left || !right) return (left ? 1 : 0) - (right ? 1 : 0);
if (left.major !== right.major) {
return left.major - right.major;
}
if (left.minor !== right.minor) {
return left.minor - right.minor;
}
return left.patch - right.patch;
}
function getCanonicalModelId(modelId: string): string {
const p = modelId.lastIndexOf("/");
return p !== -1 ? modelId.slice(p + 1) : modelId;
}
+5684 -871
View File
File diff suppressed because it is too large Load Diff
+2 -17
View File
@@ -1,3 +1,4 @@
import { enrichModelThinking } from "./model-thinking";
import MODELS from "./models.json" with { type: "json" };
import type { Api, KnownProvider, Model, Usage } from "./types";
@@ -13,7 +14,7 @@ const modelRegistry: Map<string, Map<string, Model<Api>>> = new Map();
for (const [provider, models] of Object.entries(MODELS)) {
const providerModels = new Map<string, Model<Api>>();
for (const [id, model] of Object.entries(models)) {
providerModels.set(id, model as Model<Api>);
providerModels.set(id, enrichModelThinking(model as Model<Api>));
}
modelRegistry.set(provider, providerModels);
}
@@ -42,22 +43,6 @@ export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
return usage.cost;
}
/**
* Check if a model supports xhigh thinking level.
*
* Supported today:
* - GPT-5.1 Codex Max
* - GPT-5.2 / GPT-5.3 model families
* - Anthropic Messages API Opus 4.6 models (xhigh maps to adaptive effort "max"), or other models that support budget-based thinking
*/
export function supportsXhigh<TApi extends Api>(model: Model<TApi>): boolean {
if (model.id.includes("gpt-5.2") || model.id.includes("gpt-5.3") || model.id.includes("gpt-5.1-codex-max")) {
return true;
}
return model.api === "anthropic-messages";
}
/**
* Check if two models are equal by comparing both their id and provider.
* Returns false if either model is null or undefined.
-1
View File
@@ -1,5 +1,4 @@
export * from "./descriptors";
export * from "./google";
export * from "./model-policies";
export * from "./openai-compat";
export * from "./special";
@@ -1,97 +0,0 @@
/**
* Post-processing policies applied to generated model catalogs.
*
* Each policy corrects known upstream metadata errors or normalizes model
* properties that differ from the canonical values. Keeping these in a
* dedicated module makes them explicit, isolated, and testable.
*/
import type { Api, Model } from "../types";
const CLOUDFLARE_AI_GATEWAY_BASE_URL = "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic";
/**
* Static fallback model injected when Cloudflare AI Gateway discovery
* returns no results. Ensures the provider always has at least one usable
* model entry in the catalog.
*/
export const CLOUDFLARE_FALLBACK_MODEL: Model<"anthropic-messages"> = {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
api: "anthropic-messages",
provider: "cloudflare-ai-gateway",
baseUrl: CLOUDFLARE_AI_GATEWAY_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
},
contextWindow: 200000,
maxTokens: 64000,
};
/**
* Apply upstream metadata corrections to a mutable array of models.
*
* Corrections include cache-pricing fixes and context-window clamps where
* provider APIs or models.dev report incorrect values.
*/
export function applyGeneratedModelPolicies(models: Model<Api>[]): void {
for (const model of models) {
// Claude Opus 4.5: models.dev reports 3x the correct cache pricing
if (model.provider === "anthropic" && model.id === "claude-opus-4-5") {
model.cost.cacheRead = 0.5;
model.cost.cacheWrite = 6.25;
}
// Bedrock Opus 4.6: upstream cache pricing is incorrect
if (model.provider === "amazon-bedrock" && model.id.includes("anthropic.claude-opus-4-6-v1")) {
model.cost.cacheRead = 0.5;
model.cost.cacheWrite = 6.25;
}
// Opus 4.6 / Sonnet 4.6: 1M context is beta; clamp to 200K
if (
model.id.includes("opus-4-6") ||
model.id.includes("opus-4.6") ||
model.id.includes("sonnet-4-6") ||
model.id.includes("sonnet-4.6")
) {
model.contextWindow = 200000;
}
// OpenCode variants: Claude Sonnet 4/4.5 listed with 1M context, actual limit is 200K
if (
(model.provider === "opencode-zen" || model.provider === "opencode-go") &&
(model.id === "claude-sonnet-4-5" || model.id === "claude-sonnet-4")
) {
model.contextWindow = 200000;
}
// Codex models: 400K figure includes output budget; input window is 272K
if (model.id.includes("codex") && !model.id.includes("codex-spark")) {
model.contextWindow = 272000;
}
}
}
/**
* Link `-spark` model variants to their base models for context promotion.
*
* When a spark model's context is exhausted, the agent can promote to the
* corresponding full model. This sets `contextPromotionTarget` on each
* spark variant that has a matching base model.
*/
export function linkSparkPromotionTargets(models: Model<Api>[]): void {
for (const candidate of models) {
if (!candidate.id.endsWith("-spark")) continue;
const baseId = candidate.id.slice(0, -"-spark".length);
const fallback = models.find(
model => model.provider === candidate.provider && model.api === candidate.api && model.id === baseId,
);
if (!fallback) continue;
candidate.contextPromotionTarget = `${fallback.provider}/${fallback.id}`;
}
}
+32 -71
View File
@@ -21,15 +21,15 @@ import {
} from "@aws-sdk/client-bedrock-runtime";
import { $env } from "@oh-my-pi/pi-utils";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import type { Effort } from "../model-thinking";
import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "../model-thinking";
import { calculateCost } from "../models";
import type { ThinkingEffort, ThinkingLevel } from "../thinking";
import type {
Api,
AssistantMessage,
CacheRetention,
Context,
Model,
SimpleStreamOptions,
StopReason,
StreamFunction,
StreamOptions,
@@ -51,7 +51,7 @@ export interface BedrockOptions extends StreamOptions {
profile?: string;
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
/* See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-reasoning.html for supported models. */
reasoning?: ThinkingLevel;
reasoning?: Effort;
/* Custom token budgets per thinking level. Overrides default budgets. */
thinkingBudgets?: ThinkingBudgets;
/* Only supported by Claude 4.x models, see https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-extended-thinking.html#claude-messages-extended-thinking-tool-use-interleaved */
@@ -591,85 +591,46 @@ function mapStopReason(reason: string | undefined): StopReason {
}
}
/** Check if the model supports adaptive thinking (Opus 4.6+ / Sonnet 4.6+). */
function supportsAdaptiveThinking(modelId: string): boolean {
return (
modelId.includes("opus-4-6") ||
modelId.includes("opus-4.6") ||
modelId.includes("sonnet-4-6") ||
modelId.includes("sonnet-4.6")
);
}
/** Map a thinking level to an adaptive effort value. */
function mapThinkingLevelToEffort(level: SimpleStreamOptions["reasoning"]): "low" | "medium" | "high" | "max" {
switch (level) {
case "minimal":
case "low":
return "low";
case "medium":
return "medium";
case "high":
return "high";
case "xhigh":
return "max";
default:
return "high";
}
}
function buildAdditionalModelRequestFields(
model: Model<"bedrock-converse-stream">,
options: BedrockOptions,
): Record<string, any> | undefined {
const reasoning = options.reasoning;
if (!reasoning || !model.reasoning || reasoning === "off") {
if (!reasoning || !model.reasoning) {
return undefined;
}
if (model.id.includes("anthropic.claude")) {
// Opus 4.6+ / Sonnet 4.6+ uses adaptive thinking with effort levels
if (supportsAdaptiveThinking(model.id)) {
let effort = mapThinkingLevelToEffort(reasoning);
// "max" effort is only supported on Opus 4.6; clamp to "high" for Sonnet 4.6
const supportsMax = model.id.includes("opus-4-6") || model.id.includes("opus-4.6");
if (effort === "max" && !supportsMax) {
effort = "high";
}
const result: Record<string, any> = {
thinking: { type: "adaptive" },
output_config: { effort },
};
return result;
}
const defaultBudgets: Record<ThinkingEffort, number> = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
xhigh: 16384, // Claude doesn't support xhigh, clamp to high
const mode = model.thinking?.mode;
if (mode === "anthropic-adaptive") {
const effort = mapEffortToAnthropicAdaptiveEffort(model, reasoning);
return {
thinking: { type: "adaptive" },
output_config: { effort },
};
// Custom budgets override defaults (xhigh not in ThinkingBudgets, use high)
const level = reasoning === "xhigh" ? "high" : reasoning;
const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[level];
const result: Record<string, any> = {
thinking: {
type: "enabled",
budget_tokens: budget,
},
};
if (options.interleavedThinking && !supportsAdaptiveThinking(model.id)) {
result.anthropic_beta = ["interleaved-thinking-2025-05-14"];
}
return result;
}
return undefined;
const level = requireSupportedEffort(model, reasoning);
const defaultBudgets: Record<Effort, number> = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
xhigh: 32768,
};
const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[level];
const result: Record<string, any> = {
thinking: {
type: "enabled",
budget_tokens: budget,
},
};
if (options.interleavedThinking) {
result.anthropic_beta = ["interleaved-thinking-2025-05-14"];
}
return result;
}
function createImageBlock(mimeType: string, data: string) {
+9 -41
View File
@@ -8,6 +8,7 @@ import type {
MessageParam,
} from "@anthropic-ai/sdk/resources/messages";
import { $env, abortableSleep, isEnoent } from "@oh-my-pi/pi-utils";
import { mapEffortToAnthropicAdaptiveEffort } from "../model-thinking";
import { calculateCost } from "../models";
import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream";
import type {
@@ -846,19 +847,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
return stream;
};
/**
* Check if a model supports adaptive thinking (Opus 4.6+)
*/
function supportsAdaptiveThinking(modelId: string): boolean {
// Opus/Sonnet 4.6 model IDs (with or without date suffix)
return (
modelId.includes("opus-4-6") ||
modelId.includes("opus-4.6") ||
modelId.includes("sonnet-4-6") ||
modelId.includes("sonnet-4.6")
);
}
export type AnthropicSystemBlock = {
type: "text";
text: string;
@@ -914,26 +902,6 @@ export function buildAnthropicSystemBlocks(
return blocks.length > 0 ? blocks : undefined;
}
/**
* Map ThinkingLevel to Anthropic effort levels for adaptive thinking
*/
function mapThinkingLevelToEffort(level: SimpleStreamOptions["reasoning"]): AnthropicEffort {
switch (level) {
case "minimal":
return "low";
case "low":
return "low";
case "medium":
return "medium";
case "high":
return "high";
case "xhigh":
return "max";
default:
return "high";
}
}
export function normalizeExtraBetas(betas?: string[] | string): string[] {
if (!betas) return [];
const raw = Array.isArray(betas) ? betas : betas.split(",");
@@ -1303,9 +1271,13 @@ function buildParams(
}
if (options?.thinkingEnabled && model.reasoning) {
if (supportsAdaptiveThinking(model.id)) {
const mode = model.thinking?.mode;
const requestedEffort = options.reasoning;
const effort =
options.effort ?? (requestedEffort ? mapEffortToAnthropicAdaptiveEffort(model, requestedEffort) : undefined);
if (mode === "anthropic-adaptive") {
params.thinking = { type: "adaptive" };
const effort = options.effort ?? mapThinkingLevelToEffort(options.reasoning);
if (effort) {
params.output_config = { effort };
}
@@ -1314,12 +1286,8 @@ function buildParams(
type: "enabled",
budget_tokens: options.thinkingBudgetTokens || 1024,
};
// Opus 4.5 supports effort alongside budget-based thinking
if (model.id.includes("opus-4-5") || model.id.includes("opus-4.5")) {
const effort = options.effort ?? mapThinkingLevelToEffort(options.reasoning);
if (effort) {
params.output_config = { effort };
}
if (mode === "anthropic-budget-effort" && effort) {
params.output_config = { effort };
}
}
}
+1 -4
View File
@@ -267,10 +267,7 @@ export function streamGitLabDuo(
...options.headers,
};
const reasoningEffort =
options.reasoning === "off"
? undefined
: (options.reasoning as "minimal" | "low" | "medium" | "high" | "xhigh" | undefined);
const reasoningEffort = options.reasoning;
const inner =
mapping.provider === "anthropic"
+4 -4
View File
@@ -385,13 +385,13 @@ function buildParams(
}
if (options.thinking?.enabled && model.reasoning) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
const cfg: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
cfg.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
} else if (options.thinking.budgetTokens !== undefined) {
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
cfg.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
config.thinkingConfig = cfg;
}
if (options.signal) {
+4 -4
View File
@@ -348,14 +348,14 @@ function buildParams(
}
if (options.thinking?.enabled && model.reasoning) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
const cfg: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
// Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values
thinkingConfig.thinkingLevel = options.thinking.level as any;
cfg.thinkingLevel = options.thinking.level as any;
} else if (options.thinking.budgetTokens !== undefined) {
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
cfg.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
config.thinkingConfig = cfg;
}
if (options.signal) {
+2 -2
View File
@@ -62,7 +62,7 @@ export function streamKimi(
// Calculate thinking budget from reasoning level
const reasoning = options?.reasoning;
const reasoningEffort = reasoning === "off" ? undefined : reasoning;
const reasoningEffort = reasoning;
const thinkingEnabled = !!reasoningEffort && model.reasoning;
const thinkingBudget = reasoningEffort
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
@@ -90,7 +90,7 @@ export function streamKimi(
}
} else {
// OpenAI format - use original model with Kimi headers
const reasoningEffort = options?.reasoning === "off" ? undefined : options?.reasoning;
const reasoningEffort = options?.reasoning;
const innerStream = streamOpenAICompletions(model, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
@@ -383,7 +383,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
include: options?.include,
};
const transformedBody = await transformRequestBody(params, codexOptions, systemPrompt);
const transformedBody = await transformRequestBody(params, model, codexOptions, systemPrompt);
options?.onPayload?.(transformedBody);
const reasoningEffort = transformedBody.reasoning?.effort ?? null;
@@ -1,3 +1,7 @@
import type { Effort } from "../../model-thinking";
import { requireSupportedEffort } from "../../model-thinking";
import type { Api, Model } from "../../types";
export interface ReasoningConfig {
effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
summary: "auto" | "concise" | "detailed" | null;
@@ -47,30 +51,10 @@ export interface RequestBody {
[key: string]: unknown;
}
function clampReasoningEffort(model: string, effort: ReasoningConfig["effort"]): ReasoningConfig["effort"] {
// Codex backend expects exact model IDs. Do not normalize model names here.
const modelId = model.includes("/") ? model.split("/").pop()! : model;
// gpt-5.1 does not support xhigh.
if (modelId === "gpt-5.1" && effort === "xhigh") {
return "high";
}
if ((modelId.startsWith("gpt-5.2") || modelId.startsWith("gpt-5.3")) && effort === "minimal") {
return "low";
}
// gpt-5.1-codex-mini only supports medium/high.
if (modelId === "gpt-5.1-codex-mini") {
return effort === "high" || effort === "xhigh" ? "high" : "medium";
}
return effort;
}
function getReasoningConfig(model: string, options: CodexRequestOptions): ReasoningConfig {
function getReasoningConfig(model: Model<Api>, options: CodexRequestOptions): ReasoningConfig {
return {
effort: clampReasoningEffort(model, options.reasoningEffort as ReasoningConfig["effort"]),
effort:
options.reasoningEffort === "none" ? "none" : requireSupportedEffort(model, options.reasoningEffort as Effort),
summary: options.reasoningSummary ?? "detailed",
};
}
@@ -91,6 +75,7 @@ function filterInput(input: InputItem[] | undefined): InputItem[] | undefined {
export async function transformRequestBody(
body: RequestBody,
model: Model<Api>,
options: CodexRequestOptions = {},
prompt?: { instructions: string; developerMessages: string[] },
): Promise<RequestBody> {
@@ -148,7 +133,7 @@ export async function transformRequestBody(
}
if (options.reasoningEffort !== undefined) {
const reasoningConfig = getReasoningConfig(body.model, options);
const reasoningConfig = getReasoningConfig(model, options);
body.reasoning = {
...body.reasoning,
...reasoningConfig,
+2 -2
View File
@@ -59,7 +59,7 @@ export function streamSynthetic(
// Calculate thinking budget from reasoning level
const reasoning = options?.reasoning;
const reasoningEffort = reasoning === "off" ? undefined : reasoning;
const reasoningEffort = reasoning;
const thinkingEnabled = !!reasoningEffort && model.reasoning;
const thinkingBudget = reasoningEffort
? (options?.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
@@ -93,7 +93,7 @@ export function streamSynthetic(
headers: mergedHeaders,
};
const reasoningEffort = options?.reasoning === "off" ? undefined : options?.reasoning;
const reasoningEffort = options?.reasoning;
const innerStream = streamOpenAICompletions(syntheticModel, context, {
apiKey: options?.apiKey,
temperature: options?.temperature,
+34 -128
View File
@@ -3,25 +3,25 @@ import * as os from "node:os";
import * as path from "node:path";
import { $env, $pickenv } from "@oh-my-pi/pi-utils";
import { getCustomApi } from "./api-registry";
import { supportsXhigh } from "./models";
import type { Effort } from "./model-thinking";
import {
mapEffortToAnthropicAdaptiveEffort,
mapEffortToGoogleThinkingLevel,
requireSupportedEffort,
} from "./model-thinking";
import { type BedrockOptions, streamBedrock } from "./providers/amazon-bedrock";
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic";
import { streamAzureOpenAIResponses } from "./providers/azure-openai-responses";
import { type CursorOptions, streamCursor } from "./providers/cursor";
import { isGitLabDuoModel, streamGitLabDuo } from "./providers/gitlab-duo";
import { type GoogleOptions, streamGoogle } from "./providers/google";
import {
type GoogleGeminiCliOptions,
type GoogleThinkingLevel,
streamGoogleGeminiCli,
} from "./providers/google-gemini-cli";
import { type GoogleGeminiCliOptions, streamGoogleGeminiCli } from "./providers/google-gemini-cli";
import { type GoogleVertexOptions, streamGoogleVertex } from "./providers/google-vertex";
import { isKimiModel, streamKimi } from "./providers/kimi";
import { streamOpenAICodexResponses } from "./providers/openai-codex-responses";
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions";
import { streamOpenAIResponses } from "./providers/openai-responses";
import { isSyntheticModel, streamSynthetic } from "./providers/synthetic";
import type { ThinkingEffort } from "./thinking";
import type {
Api,
AssistantMessage,
@@ -304,7 +304,7 @@ const MIN_OUTPUT_TOKENS = 1024;
export const OUTPUT_FALLBACK_BUFFER = 4000;
const ANTHROPIC_USE_INTERLEAVED_THINKING = Bun.env.PI_NO_INTERLEAVED_THINKING !== "1";
export const ANTHROPIC_THINKING: Record<ThinkingEffort, number> = {
export const ANTHROPIC_THINKING: Record<Effort, number> = {
minimal: 1024,
low: 4096,
medium: 8192,
@@ -312,7 +312,7 @@ export const ANTHROPIC_THINKING: Record<ThinkingEffort, number> = {
xhigh: 32768,
};
const GOOGLE_THINKING: Record<ThinkingEffort, number> = {
const GOOGLE_THINKING: Record<Effort, number> = {
minimal: 1024,
low: 4096,
medium: 8192,
@@ -320,7 +320,7 @@ const GOOGLE_THINKING: Record<ThinkingEffort, number> = {
xhigh: 24575,
};
const BEDROCK_CLAUDE_THINKING: Record<ThinkingEffort, number> = {
const BEDROCK_CLAUDE_THINKING: Record<Effort, number> = {
minimal: 1024,
low: 2048,
medium: 8192,
@@ -331,10 +331,9 @@ const BEDROCK_CLAUDE_THINKING: Record<ThinkingEffort, number> = {
function resolveBedrockThinkingBudget(
model: Model<"bedrock-converse-stream">,
options?: SimpleStreamOptions,
): { budget: number; level: ThinkingEffort } | null {
if (!options?.reasoning || !model.reasoning || options.reasoning === "off") return null;
if (!model.id.includes("anthropic.claude")) return null;
const level = options.reasoning === "xhigh" ? "high" : options.reasoning;
): { budget: number; level: Effort } | null {
if (!options?.reasoning || !model.reasoning) return null;
const level = requireSupportedEffort(model, options.reasoning);
const budget = options.thinkingBudgets?.[level] ?? BEDROCK_CLAUDE_THINKING[level];
return { budget, level };
}
@@ -356,26 +355,6 @@ export function mapAnthropicToolChoice(choice?: ToolChoice): AnthropicOptions["t
return undefined;
}
/**
* Map ThinkingLevel to Anthropic effort levels for adaptive thinking (Opus 4.6+)
*/
function mapThinkingLevelToAnthropicEffort(level: ThinkingEffort, supportsXhigh: boolean): AnthropicOptions["effort"] {
switch (level) {
case "minimal":
return "low";
case "low":
return "low";
case "medium":
return "medium";
case "high":
return "high";
case "xhigh":
return supportsXhigh ? "max" : "high";
default:
return "high";
}
}
function mapGoogleToolChoice(
choice?: ToolChoice,
): GoogleOptions["toolChoice"] | GoogleGeminiCliOptions["toolChoice"] | GoogleVertexOptions["toolChoice"] {
@@ -408,11 +387,10 @@ function mapOpenAiToolChoice(choice?: ToolChoice): OpenAICompletionsOptions["too
function resolveOpenAiReasoningEffort<TApi extends Api>(
model: Model<TApi>,
options?: SimpleStreamOptions,
): ThinkingEffort | undefined {
): Effort | undefined {
const reasoning = options?.reasoning;
if (!reasoning || reasoning === "off") return undefined;
if (reasoning === "xhigh" && !supportsXhigh(model)) return "high";
return reasoning;
if (!reasoning) return undefined;
return requireSupportedEffort(model, reasoning);
}
const castApi = <TApi extends Api>(api: OptionsForApi<TApi>): OptionsForApi<Api> => api as OptionsForApi<Api>;
@@ -446,7 +424,7 @@ function mapOptionsForApi<TApi extends Api>(
case "anthropic-messages": {
// Explicitly disable thinking when reasoning is not specified
const reasoning = options?.reasoning;
if (!reasoning || reasoning === "off") {
if (!reasoning) {
return castApi<"anthropic-messages">({
...base,
thinkingEnabled: false,
@@ -465,14 +443,8 @@ function mapOptionsForApi<TApi extends Api>(
// For Opus 4.6+ and Sonnet 4.6+: use adaptive thinking with effort level
// For older models: use budget-based thinking
if (
model.id.includes("opus-4-6") ||
model.id.includes("opus-4.6") ||
model.id.includes("sonnet-4-6") ||
model.id.includes("sonnet-4.6")
) {
const supportsMaxEffort = model.id.includes("opus-4-6") || model.id.includes("opus-4.6");
const effort = mapThinkingLevelToAnthropicEffort(reasoning, supportsMaxEffort);
if (model.thinking?.mode === "anthropic-adaptive") {
const effort = mapEffortToAnthropicAdaptiveEffort(model, reasoning);
return castApi<"anthropic-messages">({
...base,
thinkingEnabled: true,
@@ -577,7 +549,7 @@ function mapOptionsForApi<TApi extends Api>(
// Explicitly disable thinking when reasoning is not specified
// This is needed because Gemini has "dynamic thinking" enabled by default
const reasoning = options?.reasoning;
if (!reasoning || reasoning === "off") {
if (!reasoning) {
return castApi<"google-generative-ai">({
...base,
thinking: { enabled: false },
@@ -586,16 +558,16 @@ function mapOptionsForApi<TApi extends Api>(
}
const googleModel = model as Model<"google-generative-ai">;
const effort = reasoning === "xhigh" ? "high" : reasoning;
const effort = requireSupportedEffort(googleModel, reasoning);
// Gemini 3+ models use thinkingLevel exclusively instead of thinkingBudget.
// https://ai.google.dev/gemini-api/docs/thinking#set-budget
if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel)) {
if (googleModel.thinking?.mode === "google-level") {
return castApi<"google-generative-ai">({
...base,
thinking: {
enabled: true,
level: getGemini3ThinkingLevel(effort, googleModel),
level: mapEffortToGoogleThinkingLevel(googleModel, effort),
},
toolChoice: mapGoogleToolChoice(options?.toolChoice),
});
@@ -613,7 +585,7 @@ function mapOptionsForApi<TApi extends Api>(
case "google-gemini-cli": {
const reasoning = options?.reasoning;
if (!reasoning || reasoning === "off") {
if (!reasoning) {
return castApi<"google-gemini-cli">({
...base,
thinking: { enabled: false },
@@ -621,15 +593,15 @@ function mapOptionsForApi<TApi extends Api>(
});
}
const effort = reasoning === "xhigh" ? "high" : reasoning;
const effort = requireSupportedEffort(model, reasoning);
// Gemini 3+ models use thinkingLevel instead of thinkingBudget
if (isGemini3ProModelId(model.id) || isGemini3FlashModelId(model.id)) {
return castApi<"google-vertex">({
if (model.thinking?.mode === "google-level") {
return castApi<"google-gemini-cli">({
...base,
thinking: {
enabled: true,
level: getGeminiCliThinkingLevel(effort, model.id),
level: mapEffortToGoogleThinkingLevel(model, effort),
},
toolChoice: mapGoogleToolChoice(options?.toolChoice),
});
@@ -665,7 +637,7 @@ function mapOptionsForApi<TApi extends Api>(
case "google-vertex": {
// Explicitly disable thinking when reasoning is not specified
const reasoning = options?.reasoning;
if (!reasoning || reasoning === "off") {
if (!reasoning) {
return castApi<"google-vertex">({
...base,
thinking: { enabled: false },
@@ -674,15 +646,15 @@ function mapOptionsForApi<TApi extends Api>(
}
const vertexModel = model as Model<"google-vertex">;
const effort = reasoning === "xhigh" ? "high" : reasoning;
const effort = requireSupportedEffort(vertexModel, reasoning);
const geminiModel = vertexModel as unknown as Model<"google-generative-ai">;
if (isGemini3ProModel(geminiModel) || isGemini3FlashModel(geminiModel)) {
if (geminiModel.thinking?.mode === "google-level") {
return castApi<"google-vertex">({
...base,
thinking: {
enabled: true,
level: getGemini3ThinkingLevel(effort, geminiModel),
level: mapEffortToGoogleThinkingLevel(geminiModel, effort),
},
toolChoice: mapGoogleToolChoice(options?.toolChoice),
});
@@ -713,78 +685,12 @@ function mapOptionsForApi<TApi extends Api>(
}
}
function isGemini3ProModelId(modelId: string): boolean {
return /3(?:\.\d+)?-pro/.test(modelId);
}
function isGemini3FlashModelId(modelId: string): boolean {
return /3(?:\.\d+)?-flash/.test(modelId);
}
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
// Covers gemini-3-pro, gemini-3-pro-preview, gemini-3.1-pro-preview, and future 3.x variants
return isGemini3ProModelId(model.id);
}
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
// Covers gemini-3-flash, gemini-3-flash-preview, gemini-3.1-flash, and future 3.x variants
return isGemini3FlashModelId(model.id);
}
function getGemini3ThinkingLevel(effort: ThinkingEffort, model: Model<"google-generative-ai">): GoogleThinkingLevel {
if (isGemini3ProModel(model)) {
// Gemini 3 Pro only supports LOW/HIGH (for now)
switch (effort) {
case "minimal":
case "low":
return "LOW";
default:
return "HIGH";
}
}
// Gemini 3 Flash supports all four levels
switch (effort) {
case "minimal":
return "MINIMAL";
case "low":
return "LOW";
case "medium":
return "MEDIUM";
default:
return "HIGH";
}
}
function getGeminiCliThinkingLevel(effort: ThinkingEffort, modelId: string): GoogleThinkingLevel {
if (isGemini3ProModelId(modelId)) {
// Gemini 3 Pro only supports LOW/HIGH (for now)
switch (effort) {
case "minimal":
case "low":
return "LOW";
default:
return "HIGH";
}
}
// Gemini 3 Flash supports all four levels
switch (effort) {
case "minimal":
return "MINIMAL";
case "low":
return "LOW";
case "medium":
return "MEDIUM";
default:
return "HIGH";
}
}
function getGoogleBudget(
model: Model<"google-generative-ai">,
effort: ThinkingEffort,
effort: Effort,
customBudgets?: ThinkingBudgets,
): number {
effort = effort === "xhigh" ? "high" : effort;
requireSupportedEffort(model, effort);
// Custom budgets take precedence if provided for this level
if (customBudgets?.[effort] !== undefined) {
-85
View File
@@ -1,85 +0,0 @@
/** Provider-level thinking levels (no "off"), ordered least to most. */
export type ThinkingEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
/**
* ThinkingLevel extended with "off" to disable reasoning entirely.
* Used in UI, config, session state, and CLI args.
* "off" is never sent to providers — callers strip it before streaming.
*/
export type ThinkingLevel = ThinkingEffort | "off";
/**
* ThinkingSelector extended with "inherit" to indicate the role should
* use the session-level default rather than an explicit choice.
* Used in per-role model assignment UI.
*/
export type ThinkingMode = ThinkingLevel | "inherit";
/** Metadata for a thinking mode. */
export type ThinkingMetadata = {
/** The value of the thinking mode. */
value: ThinkingMode;
/** The label to display for the thinking mode. */
label: string;
/** The description to display for the thinking mode. */
description: string;
};
const THINKING_META: Record<ThinkingMode, ThinkingMetadata> = {
inherit: { value: "inherit", label: "inherit", description: "Inherit session default" },
off: { value: "off", label: "off", description: "No reasoning" },
minimal: { value: "minimal", label: "min", description: "Very brief reasoning (~1k tokens)" },
low: { value: "low", label: "low", description: "Light reasoning (~2k tokens)" },
medium: { value: "medium", label: "medium", description: "Moderate reasoning (~8k tokens)" },
high: { value: "high", label: "high", description: "Deep reasoning (~16k tokens)" },
xhigh: { value: "xhigh", label: "xhigh", description: "Maximum reasoning (~32k tokens)" },
};
const F_LEVEL = 3;
const F_SEL = 2;
const F_MODE = 1;
const F_THINKING: Record<string, number> = {
inherit: F_MODE,
off: F_SEL,
minimal: F_LEVEL,
low: F_LEVEL,
medium: F_LEVEL,
high: F_LEVEL,
xhigh: F_LEVEL,
};
// Parses an unknown value and returns a ThinkingLevel if valid, otherwise undefined.
export function parseThinkingEffort(level: string | null | undefined): ThinkingEffort | undefined {
return level && (F_THINKING[level] ?? 0) >= F_LEVEL ? (level as ThinkingEffort) : undefined;
}
// Parses an unknown value and returns a ThinkingSelector if valid, otherwise undefined.
export function parseThinkingLevel(level: string | null | undefined): ThinkingLevel | undefined {
return level && (F_THINKING[level] ?? 0) >= F_SEL ? (level as ThinkingLevel) : undefined;
}
// Parses an unknown value and returns a ThinkingMode if valid, otherwise undefined.
export function parseThinkingMode(level: string | null | undefined): ThinkingMode | undefined {
return level && (F_THINKING[level] ?? 0) >= F_MODE ? (level as ThinkingMode) : undefined;
}
/** Get the information for a thinking mode. */
export function getThinkingMetadata(mode: ThinkingMode): ThinkingMetadata {
return THINKING_META[mode];
}
const REG_LVL: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
const XHI_LVL: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
/** Returns the available thinking modes for a model based on whether it supports xhigh. */
export function getAvailableThinkingLevels(hasXhigh: boolean = true): ReadonlyArray<ThinkingLevel> {
return hasXhigh ? XHI_LVL : REG_LVL;
}
const REG_EFF: readonly ThinkingEffort[] = ["minimal", "low", "medium", "high"];
const XHI_EFF: readonly ThinkingEffort[] = ["minimal", "low", "medium", "high", "xhigh"];
export function getAvailableThinkingEfforts(hasXhigh: boolean = true): ReadonlyArray<ThinkingEffort> {
return hasXhigh ? XHI_EFF : REG_EFF;
}
+23 -3
View File
@@ -66,6 +66,24 @@ export type OptionsForApi<TApi extends Api> =
| StreamOptions
| (TApi extends keyof ApiOptionsMap ? ApiOptionsMap[TApi] : never);
/** Canonical thinking transport used by a model. */
export type ThinkingControlMode =
| "effort"
| "budget"
| "google-level"
| "anthropic-adaptive"
| "anthropic-budget-effort";
/** Per-model thinking capabilities used to clamp and map user-facing effort levels. */
export interface ThinkingConfig {
/** Least intensive supported user-facing effort level. */
minLevel: Effort;
/** Most intensive supported user-facing effort level. */
maxLevel: Effort;
/** Provider-specific transport used to encode the selected effort. */
mode: ThinkingControlMode;
}
export type KnownProvider =
| "amazon-bedrock"
| "anthropic"
@@ -110,10 +128,10 @@ export type KnownProvider =
| "lm-studio";
export type Provider = KnownProvider | string;
import type { ThinkingEffort, ThinkingLevel } from "./thinking";
import type { Effort } from "./model-thinking";
/** Token budgets for each thinking level (token-based providers only) */
export type ThinkingBudgets = { [key in ThinkingEffort]?: number };
export type ThinkingBudgets = { [key in Effort]?: number };
export type MessageAttribution = "user" | "agent";
@@ -192,7 +210,7 @@ export interface StreamOptions {
// Unified options with reasoning passed to streamSimple() and completeSimple()
export interface SimpleStreamOptions extends StreamOptions {
reasoning?: ThinkingLevel;
reasoning?: Effort;
/** Custom token budgets for thinking levels (token-based providers only) */
thinkingBudgets?: ThinkingBudgets;
/** Cursor exec handlers for local tool execution */
@@ -476,6 +494,8 @@ export interface Model<TApi extends Api = any> {
contextPromotionTarget?: string;
/** Provider-assigned priority value (lower = higher priority). */
priority?: number;
/** Canonical thinking capability metadata for this model. */
thinking?: ThinkingConfig;
/** Compatibility overrides for openai-completions API. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions" ? OpenAICompat : never;
}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { Effort } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "../src/models";
import { streamSimple } from "../src/stream";
import type { Context, Model } from "../src/types";
@@ -11,7 +12,7 @@ interface GeminiCliThinkingConfig {
interface CapturedRequestBody {
request?: {
generationConfig?: {
thinkingConfig?: GeminiCliThinkingConfig;
thinking?: GeminiCliThinkingConfig;
};
};
}
@@ -35,10 +36,10 @@ const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
function extractThinkingConfig(bodyText: string | undefined): GeminiCliThinkingConfig | undefined {
function extractThinking(bodyText: string | undefined): GeminiCliThinkingConfig | undefined {
if (!bodyText) return undefined;
const parsed = JSON.parse(bodyText) as CapturedRequestBody;
return parsed.request?.generationConfig?.thinkingConfig;
return parsed.request?.generationConfig?.thinking;
}
describe("google-gemini-cli Gemini 3.x thinking mapping", () => {
@@ -52,7 +53,7 @@ describe("google-gemini-cli Gemini 3.x thinking mapping", () => {
it("includes gemini-3.1-pro-preview in bundled google-gemini-cli models", () => {
expect(getBundledModel("google-gemini-cli", "gemini-3.1-pro-preview")?.id).toBe("gemini-3.1-pro-preview");
});
it("uses thinkingLevel for gemini-3.1-pro-preview", async () => {
it("uses thinkingLevel for gemini-3.1-pro-preview when the effort is supported", async () => {
let requestBody: string | undefined;
globalThis.fetch = vi.fn(async (_input, init) => {
requestBody = typeof init?.body === "string" ? init.body : undefined;
@@ -61,13 +62,29 @@ describe("google-gemini-cli Gemini 3.x thinking mapping", () => {
const stream = streamSimple(createModel("gemini-3.1-pro-preview"), context, {
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
reasoning: "medium",
reasoning: Effort.High,
});
await stream.result();
const thinkingConfig = extractThinkingConfig(requestBody);
expect(thinkingConfig?.thinkingLevel).toBe("HIGH");
expect(thinkingConfig?.thinkingBudget).toBeUndefined();
const thinking = extractThinking(requestBody);
expect(thinking?.thinkingLevel).toBe("HIGH");
expect(thinking?.thinkingBudget).toBeUndefined();
});
it("rejects unsupported gemini-3.1-pro-preview efforts instead of promoting them", () => {
let requestBody: string | undefined;
globalThis.fetch = vi.fn(async (_input, init) => {
requestBody = typeof init?.body === "string" ? init.body : undefined;
return new Response('{"error":{"message":"bad request"}}', { status: 400 });
}) as unknown as typeof fetch;
expect(() =>
streamSimple(createModel("gemini-3.1-pro-preview"), context, {
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
reasoning: Effort.Medium,
}),
).toThrow(/Supported efforts: low, high/);
expect(requestBody).toBeUndefined();
});
it("uses thinkingLevel for gemini-3.1-flash-preview", async () => {
@@ -79,13 +96,13 @@ describe("google-gemini-cli Gemini 3.x thinking mapping", () => {
const stream = streamSimple(createModel("gemini-3.1-flash-preview"), context, {
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
reasoning: "medium",
reasoning: Effort.Medium,
});
await stream.result();
const thinkingConfig = extractThinkingConfig(requestBody);
expect(thinkingConfig?.thinkingLevel).toBe("MEDIUM");
expect(thinkingConfig?.thinkingBudget).toBeUndefined();
const thinking = extractThinking(requestBody);
expect(thinking?.thinkingLevel).toBe("MEDIUM");
expect(thinking?.thinkingBudget).toBeUndefined();
});
it("keeps thinkingBudget for gemini-2.5-pro", async () => {
@@ -97,12 +114,12 @@ describe("google-gemini-cli Gemini 3.x thinking mapping", () => {
const stream = streamSimple(createModel("gemini-2.5-pro"), context, {
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
reasoning: "medium",
reasoning: Effort.Medium,
});
await stream.result();
const thinkingConfig = extractThinkingConfig(requestBody);
expect(thinkingConfig?.thinkingLevel).toBeUndefined();
expect(thinkingConfig?.thinkingBudget).toBeDefined();
const thinking = extractThinking(requestBody);
expect(thinking?.thinkingLevel).toBeUndefined();
expect(thinking?.thinkingBudget).toBeDefined();
});
});
+278
View File
@@ -0,0 +1,278 @@
import { describe, expect, it } from "bun:test";
import {
applyGeneratedModelPolicies,
clampThinkingLevelForModel,
Effort,
enrichModelThinking,
linkSparkPromotionTargets,
mapEffortToAnthropicAdaptiveEffort,
mapEffortToGoogleThinkingLevel,
requireSupportedEffort,
} from "@oh-my-pi/pi-ai/model-thinking";
import type { Api, Model, Provider } from "@oh-my-pi/pi-ai/types";
function createModel<TApi extends Api>(overrides: {
id: string;
api: TApi;
provider: Provider;
reasoning?: boolean;
}): Model<TApi> {
return enrichModelThinking({
id: overrides.id,
name: overrides.id,
api: overrides.api,
provider: overrides.provider,
baseUrl: "",
reasoning: overrides.reasoning ?? true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
});
}
describe("model thinking metadata", () => {
it("stores supported efforts for Codex mini in model metadata", () => {
const model = createModel({
id: "gpt-5.1-codex-mini",
api: "openai-codex-responses",
provider: "openai-codex",
});
expect(model.thinking).toEqual({
mode: "effort",
minLevel: Effort.Medium,
maxLevel: Effort.High,
});
expect(() => requireSupportedEffort(model, Effort.Low)).toThrow(/Supported efforts: medium, high/);
expect(() => requireSupportedEffort(model, Effort.XHigh)).toThrow(/Supported efforts: medium, high/);
});
it("stores xhigh support directly in metadata for GPT-5.2", () => {
const model = createModel({
id: "gpt-5.2-codex",
api: "openai-codex-responses",
provider: "openai-codex",
});
expect(model.thinking).toEqual({
mode: "effort",
minLevel: Effort.Low,
maxLevel: Effort.XHigh,
});
expect(requireSupportedEffort(model, Effort.XHigh)).toBe(Effort.XHigh);
});
it("maps Gemini 3 Pro only for supported levels", () => {
const model = createModel({
id: "gemini-3-pro-preview",
api: "google-generative-ai",
provider: "google",
});
expect(model.thinking).toEqual({
mode: "google-level",
minLevel: Effort.Low,
maxLevel: Effort.High,
});
expect(mapEffortToGoogleThinkingLevel(model, Effort.Low)).toBe("LOW");
expect(mapEffortToGoogleThinkingLevel(model, Effort.High)).toBe("HIGH");
expect(() => mapEffortToGoogleThinkingLevel(model, Effort.Medium)).toThrow(/not supported/);
});
it("encodes anthropic transport mode in metadata", () => {
const opus45 = createModel({
id: "claude-opus-4-5",
api: "anthropic-messages",
provider: "anthropic",
});
const opus46 = createModel({
id: "claude-opus-4.6",
api: "anthropic-messages",
provider: "anthropic",
});
const sonnet46 = createModel({
id: "claude-sonnet-4.6",
api: "anthropic-messages",
provider: "anthropic",
});
expect(opus45.thinking?.mode).toBe("anthropic-budget-effort");
expect(opus46.thinking?.mode).toBe("anthropic-adaptive");
expect(sonnet46.thinking?.mode).toBe("anthropic-adaptive");
expect(opus46.thinking).toEqual({
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.XHigh,
});
expect(sonnet46.thinking).toEqual({
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
});
expect(mapEffortToAnthropicAdaptiveEffort(opus46, Effort.XHigh)).toBe("max");
expect(() => mapEffortToAnthropicAdaptiveEffort(sonnet46, Effort.XHigh)).toThrow(/not supported/);
});
});
describe("generated model policies", () => {
it("refreshes thinking metadata and applies parsed catalog corrections", () => {
const models: Model<Api>[] = [
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://example.com",
reasoning: true,
thinking: {
mode: "budget",
minLevel: Effort.High,
maxLevel: Effort.High,
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 1.5, cacheWrite: 18.75 },
contextWindow: 1000000,
maxTokens: 32000,
},
{
id: "anthropic.claude-opus-4-6-v1:0",
name: "Claude Opus 4.6",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 1.5, cacheWrite: 18.75 },
contextWindow: 1000000,
maxTokens: 32000,
},
{
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 32000,
},
];
applyGeneratedModelPolicies(models);
expect(models[0]?.thinking).toEqual({
mode: "anthropic-budget-effort",
minLevel: Effort.Minimal,
maxLevel: Effort.XHigh,
});
expect(models[0]?.cost.cacheRead).toBe(0.5);
expect(models[0]?.cost.cacheWrite).toBe(6.25);
expect(models[1]?.thinking).toEqual({
mode: "budget",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
});
expect(models[1]?.cost.cacheRead).toBe(0.5);
expect(models[1]?.cost.cacheWrite).toBe(6.25);
expect(models[1]?.contextWindow).toBe(200000);
expect(models[2]?.contextWindow).toBe(272000);
});
it("links spark variants to their base models", () => {
const models = [
createModel({
id: "gpt-5.2-codex-spark",
api: "openai-codex-responses",
provider: "openai-codex",
}),
createModel({
id: "gpt-5.2-codex",
api: "openai-codex-responses",
provider: "openai-codex",
}),
];
linkSparkPromotionTargets(models);
expect(models[0]?.contextPromotionTarget).toBe("openai-codex/gpt-5.2-codex");
});
});
describe("model thinking runtime helpers", () => {
it("clamps from explicit metadata instead of inferring from model id", () => {
const model: Model<"openai-codex-responses"> = {
id: "custom-reasoner",
name: "Custom Reasoner",
api: "openai-codex-responses",
provider: "custom",
baseUrl: "https://example.com",
reasoning: true,
thinking: {
mode: "effort",
minLevel: Effort.Medium,
maxLevel: Effort.High,
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
};
expect(clampThinkingLevelForModel(model, Effort.Minimal)).toBe(Effort.Medium);
expect(clampThinkingLevelForModel(model, Effort.XHigh)).toBe(Effort.High);
expect(clampThinkingLevelForModel(model, Effort.High)).toBe(Effort.High);
});
it('forces "off" for non-reasoning models', () => {
const model = createModel({
id: "plain-model",
api: "openai-responses",
provider: "openai",
reasoning: false,
});
expect(clampThinkingLevelForModel(model, Effort.High)).toBeUndefined();
});
it("rejects reasoning models that are missing thinking metadata at runtime", () => {
const model = {
id: "broken-reasoner",
name: "Broken Reasoner",
api: "openai-responses",
provider: "custom",
baseUrl: "https://example.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
} as Model<"openai-responses">;
expect(() => requireSupportedEffort(model, Effort.High)).toThrow(/missing thinking metadata/);
});
it("drops empty thinking metadata so presence checks stay meaningful", () => {
const model = enrichModelThinking({
id: "plain-model",
name: "Plain Model",
api: "openai-responses",
provider: "custom",
baseUrl: "https://example.com",
reasoning: false,
thinking: {
mode: "effort",
minLevel: Effort.High,
maxLevel: Effort.Low,
},
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
} satisfies Model<"openai-responses">);
expect(model.thinking).toBeUndefined();
});
});
+19 -2
View File
@@ -1,5 +1,22 @@
import { describe, expect, it } from "bun:test";
import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
import { type RequestBody, transformRequestBody } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
import type { Model } from "@oh-my-pi/pi-ai/types";
function createCodexModel(id: string): Model<"openai-codex-responses"> {
return enrichModelThinking({
id,
name: id,
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272000,
maxTokens: 128000,
});
}
describe("openai-codex include handling", () => {
it("always includes reasoning.encrypted_content when caller include is custom", async () => {
@@ -7,7 +24,7 @@ describe("openai-codex include handling", () => {
model: "gpt-5.1-codex",
};
const transformed = await transformRequestBody(body, { include: ["foo"] });
const transformed = await transformRequestBody(body, createCodexModel(body.model), { include: ["foo"] });
expect(transformed.include).toEqual(["foo", "reasoning.encrypted_content"]);
});
@@ -16,7 +33,7 @@ describe("openai-codex include handling", () => {
model: "gpt-5.1-codex",
};
const transformed = await transformRequestBody(body, {
const transformed = await transformRequestBody(body, createCodexModel(body.model), {
include: ["foo", "reasoning.encrypted_content"],
});
expect(transformed.include).toEqual(["foo", "reasoning.encrypted_content"]);
+7 -4
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
import {
getOpenAICodexTransportDetails,
prewarmOpenAICodexResponses,
@@ -484,7 +485,7 @@ describe("openai-codex streaming", () => {
await streamResult.result();
});
it("clamps gpt-5.3-codex minimal reasoning effort to low", async () => {
it("rejects gpt-5.3-codex minimal reasoning effort instead of clamping", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
@@ -555,7 +556,7 @@ describe("openai-codex streaming", () => {
global.fetch = fetchMock as unknown as typeof fetch;
const model: Model<"openai-codex-responses"> = {
const model = enrichModelThinking({
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "openai-codex-responses",
@@ -566,7 +567,7 @@ describe("openai-codex streaming", () => {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
});
const context: Context = {
systemPrompt: "You are a helpful assistant.",
@@ -577,7 +578,9 @@ describe("openai-codex streaming", () => {
apiKey: token,
reasoning: "minimal",
});
await streamResult.result();
const response = await streamResult.result();
expect(response.stopReason).toBe("error");
expect(response.errorMessage).toContain("Supported efforts: low, medium, high, xhigh");
});
it("does not set conversation_id/session_id headers when sessionId is not provided", async () => {
+30 -10
View File
@@ -1,10 +1,27 @@
import { describe, expect, it } from "bun:test";
import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
import { type RequestBody, transformRequestBody } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
import { parseCodexError } from "@oh-my-pi/pi-ai/providers/openai-codex/response-handler";
import type { Model } from "@oh-my-pi/pi-ai/types";
const DEFAULT_PROMPT_PREFIX =
"You are an expert coding assistant. You help users with coding tasks by reading files, executing commands";
function createCodexModel(id: string): Model<"openai-codex-responses"> {
return enrichModelThinking({
id,
name: id,
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272000,
maxTokens: 128000,
});
}
describe("openai-codex request transformer", () => {
it("filters item_reference and strips ids", async () => {
const body: RequestBody = {
@@ -28,7 +45,7 @@ describe("openai-codex request transformer", () => {
tools: [{ type: "function", name: "tool", description: "", parameters: {} }],
};
const transformed = await transformRequestBody(body, {});
const transformed = await transformRequestBody(body, createCodexModel(body.model), {});
expect(transformed.store).toBe(false);
expect(transformed.stream).toBe(true);
@@ -47,21 +64,24 @@ describe("openai-codex request transformer", () => {
});
});
describe("openai-codex reasoning effort clamping", () => {
it("clamps gpt-5.1 xhigh to high", async () => {
describe("openai-codex reasoning effort validation", () => {
it("rejects gpt-5.1 xhigh when metadata does not list it", async () => {
const body: RequestBody = { model: "gpt-5.1", input: [] };
const transformed = await transformRequestBody(body, { reasoningEffort: "xhigh" });
expect(transformed.reasoning?.effort).toBe("high");
await expect(
transformRequestBody(body, createCodexModel(body.model), { reasoningEffort: "xhigh" }),
).rejects.toThrow(/Supported efforts: minimal, low, medium, high/);
});
it("clamps gpt-5.1-codex-mini to medium/high only", async () => {
it("rejects unsupported Codex mini efforts instead of clamping", async () => {
const body: RequestBody = { model: "gpt-5.1-codex-mini", input: [] };
const low = await transformRequestBody({ ...body }, { reasoningEffort: "low" });
expect(low.reasoning?.effort).toBe("medium");
await expect(
transformRequestBody({ ...body }, createCodexModel(body.model), { reasoningEffort: "low" }),
).rejects.toThrow(/Supported efforts: medium, high/);
const xhigh = await transformRequestBody({ ...body }, { reasoningEffort: "xhigh" });
expect(xhigh.reasoning?.effort).toBe("high");
await expect(
transformRequestBody({ ...body }, createCodexModel(body.model), { reasoningEffort: "xhigh" }),
).rejects.toThrow(/Supported efforts: medium, high/);
});
});
+22 -21
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { type ChildProcess, execSync, spawn } from "node:child_process";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { Effort } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-ai/models";
import { complete, stream } from "@oh-my-pi/pi-ai/stream";
import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
@@ -534,7 +535,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle thinking",
async () => {
await handleThinking(llm, { reasoning: "high" });
await handleThinking(llm, { reasoning: Effort.High });
},
{ retry: 2 },
);
@@ -542,7 +543,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle multi-turn with thinking and tools",
async () => {
await multiTurn(llm, { reasoning: "high" });
await multiTurn(llm, { reasoning: Effort.High });
},
{ retry: 3 },
);
@@ -658,7 +659,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle thinking mode",
async () => {
await handleThinking(llm, { reasoning: "medium" });
await handleThinking(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -666,7 +667,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle multi-turn with thinking and tools",
async () => {
await multiTurn(llm, { reasoning: "medium" });
await multiTurn(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -702,7 +703,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle thinking mode",
async () => {
await handleThinking(llm, { reasoning: "medium" });
await handleThinking(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -710,7 +711,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle multi-turn with thinking and tools",
async () => {
await multiTurn(llm, { reasoning: "medium" });
await multiTurn(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -746,7 +747,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle thinking mode",
async () => {
await handleThinking(llm, { reasoning: "medium" });
await handleThinking(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -754,7 +755,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle multi-turn with thinking and tools",
async () => {
await multiTurn(llm, { reasoning: "medium" });
await multiTurn(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -790,7 +791,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle thinking mode",
async () => {
await handleThinking(llm, { reasoning: "medium" });
await handleThinking(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -798,7 +799,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle multi-turn with thinking and tools",
async () => {
await multiTurn(llm, { reasoning: "medium" });
await multiTurn(llm, { reasoning: Effort.Medium });
},
{ retry: 2 },
);
@@ -842,7 +843,7 @@ describe("Generate E2E Tests", () => {
it.skip(
"should handle thinking mode",
async () => {
await handleThinking(llm, { reasoning: "medium" });
await handleThinking(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -850,7 +851,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle multi-turn with thinking and tools",
async () => {
await multiTurn(llm, { reasoning: "medium" });
await multiTurn(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -886,7 +887,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle thinking mode",
async () => {
await handleThinking(llm, { reasoning: "medium" });
await handleThinking(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -894,7 +895,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle multi-turn with thinking and tools",
async () => {
await multiTurn(llm, { reasoning: "medium" });
await multiTurn(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -950,7 +951,7 @@ describe("Generate E2E Tests", () => {
it(
"should handle multi-turn with thinking and tools",
async () => {
await multiTurn(llm, { reasoning: "medium" });
await multiTurn(llm, { reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -1076,7 +1077,7 @@ describe("Generate E2E Tests", () => {
"should handle thinking",
async () => {
const thinkingModel = getBundledModel("github-copilot", "gpt-5-mini");
await handleThinking(thinkingModel, { apiKey: githubCopilotToken, reasoning: "high" });
await handleThinking(thinkingModel, { apiKey: githubCopilotToken, reasoning: Effort.High });
},
{ retry: 2 },
);
@@ -1085,7 +1086,7 @@ describe("Generate E2E Tests", () => {
"should handle multi-turn with thinking and tools",
async () => {
const thinkingModel = getBundledModel("github-copilot", "gpt-5-mini");
await multiTurn(thinkingModel, { apiKey: githubCopilotToken, reasoning: "high" });
await multiTurn(thinkingModel, { apiKey: githubCopilotToken, reasoning: Effort.High });
},
{ retry: 3 },
);
@@ -1318,7 +1319,7 @@ describe("Generate E2E Tests", () => {
it.skipIf(!openaiCodexToken)(
"should handle thinking",
async () => {
await handleThinking(llm, { apiKey: openaiCodexToken, reasoning: "high" });
await handleThinking(llm, { apiKey: openaiCodexToken, reasoning: Effort.High });
},
{ retry: 3 },
);
@@ -1361,7 +1362,7 @@ describe("Generate E2E Tests", () => {
tools: [calculatorTool],
},
{
reasoning: "xhigh",
reasoning: Effort.XHigh,
interleavedThinking: true,
onPayload: payload => {
capturedPayload = payload;
@@ -1490,7 +1491,7 @@ describe("Generate E2E Tests", () => {
"should handle thinking mode",
async () => {
if (!llm) return;
await handleThinking(llm, { apiKey: "test", reasoning: "medium" });
await handleThinking(llm, { apiKey: "test", reasoning: Effort.Medium });
},
{ retry: 3 },
);
@@ -1499,7 +1500,7 @@ describe("Generate E2E Tests", () => {
"should handle multi-turn with thinking and tools",
async () => {
if (!llm) return;
await multiTurn(llm, { apiKey: "test", reasoning: "medium" });
await multiTurn(llm, { apiKey: "test", reasoning: Effort.Medium });
},
{ retry: 3 },
);
+25
View File
@@ -1,8 +1,23 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed `ThinkingLevel` type to be imported from `@oh-my-pi/pi-agent-core` instead of `@oh-my-pi/pi-ai`
- Changed thinking level representation from string literals to `Effort` enum values (e.g., `Effort.High` instead of `"high"`)
- Changed `getThinkingLevel()` return type to `ThinkingLevel | undefined` to support models without thinking support
- Changed model `reasoning` property to `thinking` property with `ThinkingConfig` for explicit effort level configuration
- Changed `thinkingLevel` in session context to be optional (`ThinkingLevel | undefined`) instead of always present
### Added
- Added `thinking.ts` module with `getThinkingLevelMetadata()` and `resolveThinkingLevelForModel()` utilities for thinking level handling
- Added `ThinkingConfig` support to model definitions for specifying supported thinking effort levels per model
- Added `enrichModelThinking()` function to apply thinking configuration to models during registry initialization
- Added `clampThinkingLevelForModel()` function to constrain thinking levels to model-supported ranges
- Added `getSupportedEfforts()` function to retrieve available thinking efforts for a model
- Added `Effort` enum import from `@oh-my-pi/pi-ai` for type-safe thinking level representation
- Added `/fast` slash command to toggle OpenAI service tier priority mode for faster response processing
- Added `serviceTier` setting to control OpenAI processing priority (none, auto, default, flex, scale, priority)
- Added `compaction.remoteEnabled` setting to control use of remote compaction endpoints
@@ -13,6 +28,14 @@
### Changed
- Changed thinking level parsing to use `parseEffort()` from local thinking module instead of `parseThinkingLevel()` from pi-ai
- Changed model list display to show supported thinking efforts (e.g., "low,medium,high") instead of yes/no reasoning indicator
- Changed footer and status line to check `model.thinking` instead of `model.reasoning` for thinking level display
- Changed thinking selector to work with `Effort` type instead of `ThinkingLevel` for available levels
- Changed model resolver to return `undefined` for thinking level instead of `"off"` when no thinking is specified
- Changed compaction reasoning parameters to use `Effort` enum values instead of string literals
- Changed RPC types to use `Effort` for cycling thinking levels and `ThinkingLevel | undefined` for session state
- Changed theme thinking border color function to accept both `ThinkingLevel` and `Effort` types
- Changed context usage coloring in footer and status line to use token-aware thresholds instead of fixed percentages
- Changed compaction to preserve OpenAI remote compaction state and encrypted reasoning across sessions
- Changed compaction to skip emitting kept messages when using OpenAI remote compaction with preserved history
@@ -22,6 +45,8 @@
### Fixed
- Fixed thinking level display logic in main.ts to correctly check for undefined instead of "off"
- Fixed model registry to preserve explicit thinking configuration on runtime-registered models
- Fixed usage limit reset time calculation to use absolute `resetsAt` timestamps instead of deprecated `resetInMs` field
- Fixed compaction summary message creation to no longer be automatically added to chat during compaction (now handled by session manager)
@@ -3,6 +3,7 @@
*
* Shows how to select a specific model and thinking level.
*/
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getModel } from "@oh-my-pi/pi-ai";
import { createAgentSession, discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
@@ -32,7 +33,7 @@ console.log(
if (available.length > 0) {
const { session } = await createAgentSession({
model: available[0],
thinkingLevel: "medium", // off, low, medium, high
thinkingLevel: ThinkingLevel.Medium, // off, low, medium, high
authStorage,
modelRegistry,
});
+5 -4
View File
@@ -1,9 +1,10 @@
/**
* CLI argument parsing and help display
*/
import { getAvailableThinkingLevels, parseThinkingLevel, type ThinkingLevel } from "@oh-my-pi/pi-ai";
import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { parseEffort } from "../thinking";
import { BUILTIN_TOOLS } from "../tools";
export type Mode = "text" | "json" | "rpc";
@@ -19,7 +20,7 @@ export interface Args {
apiKey?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
thinking?: ThinkingLevel;
thinking?: Effort;
continue?: boolean;
resume?: string | true;
help?: boolean;
@@ -122,13 +123,13 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
result.tools = validTools;
} else if (arg === "--thinking" && i + 1 < args.length) {
const rawThinking = args[++i];
const thinking = parseThinkingLevel(rawThinking);
const thinking = parseEffort(rawThinking);
if (thinking !== undefined) {
result.thinking = thinking;
} else {
logger.warn("Invalid thinking level passed to --thinking", {
level: rawThinking,
validThinkingLevels: getAvailableThinkingLevels(),
validThinkingLevels: THINKING_EFFORTS,
});
}
} else if (arg === "--print" || arg === "-p") {
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* List available models with optional fuzzy search
*/
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { type Api, getSupportedEfforts, type Model } from "@oh-my-pi/pi-ai";
import { formatNumber } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { fuzzyFilter } from "../utils/fuzzy";
@@ -41,7 +41,7 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s
model: m.id,
context: formatNumber(m.contextWindow),
maxOut: formatNumber(m.maxTokens),
thinking: m.reasoning ? "yes" : "no",
thinking: m.thinking ? getSupportedEfforts(m).join(",") : m.reasoning ? "yes" : "-",
images: m.input.includes("image") ? "yes" : "no",
}));
+3 -3
View File
@@ -2,7 +2,7 @@
* Root command for the coding agent CLI.
*/
import { getAvailableThinkingLevels } from "@oh-my-pi/pi-ai";
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
import { parseArgs } from "../cli/args";
@@ -86,8 +86,8 @@ export default class Index extends Command {
description: "Comma-separated list of tools to enable (default: all)",
}),
thinking: Flags.string({
description: `Set thinking level: ${getAvailableThinkingLevels().join(", ")}`,
options: getAvailableThinkingLevels(),
description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
options: [...THINKING_EFFORTS],
}),
hook: Flags.string({
description: "Load a hook/extension file (can be used multiple times)",
@@ -4,6 +4,7 @@ import {
type Context,
createModelManager,
DEFAULT_LOCAL_TOKEN,
enrichModelThinking,
getBundledModels,
getBundledProviders,
googleAntigravityModelManagerOptions,
@@ -18,6 +19,7 @@ import {
registerCustomApi,
registerOAuthProvider,
type SimpleStreamOptions,
type ThinkingConfig,
unregisterCustomApis,
unregisterOAuthProviders,
} from "@oh-my-pi/pi-ai";
@@ -72,6 +74,28 @@ const OpenAICompatSchema = Type.Object({
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
});
const EffortSchema = Type.Union([
Type.Literal("minimal"),
Type.Literal("low"),
Type.Literal("medium"),
Type.Literal("high"),
Type.Literal("xhigh"),
]);
const ThinkingControlModeSchema = Type.Union([
Type.Literal("effort"),
Type.Literal("budget"),
Type.Literal("google-level"),
Type.Literal("anthropic-adaptive"),
Type.Literal("anthropic-budget-effort"),
]);
const ModelThinkingSchema = Type.Object({
minLevel: EffortSchema,
maxLevel: EffortSchema,
mode: ThinkingControlModeSchema,
});
// Schema for custom model definition
// Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.)
const ModelDefinitionSchema = Type.Object({
@@ -90,6 +114,7 @@ const ModelDefinitionSchema = Type.Object({
),
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
reasoning: Type.Optional(Type.Boolean()),
thinking: Type.Optional(ModelThinkingSchema),
input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
cost: Type.Optional(
Type.Object({
@@ -111,6 +136,7 @@ const ModelDefinitionSchema = Type.Object({
const ModelOverrideSchema = Type.Object({
name: Type.Optional(Type.String({ minLength: 1 })),
reasoning: Type.Optional(Type.Boolean()),
thinking: Type.Optional(ModelThinkingSchema),
input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
cost: Type.Optional(
Type.Object({
@@ -376,6 +402,7 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
const result = { ...model };
if (override.name !== undefined) result.name = override.name;
if (override.reasoning !== undefined) result.reasoning = override.reasoning;
if (override.thinking !== undefined) result.thinking = override.thinking as ThinkingConfig;
if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow;
if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
@@ -393,7 +420,7 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
result.headers = { ...model.headers, ...override.headers };
}
result.compat = mergeCompat(model.compat, override.compat);
return result;
return enrichModelThinking(result);
}
interface CustomModelDefinitionLike {
@@ -402,6 +429,7 @@ interface CustomModelDefinitionLike {
api?: Api;
baseUrl?: string;
reasoning?: boolean;
thinking?: ThinkingConfig;
input?: ("text" | "image")[];
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow?: number;
@@ -447,13 +475,14 @@ function buildCustomModel(
const withDefaults = options.useDefaults;
const cost = modelDef.cost ?? (withDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
const input = modelDef.input ?? (withDefaults ? ["text"] : undefined);
return {
return enrichModelThinking({
id: modelDef.id,
name: modelDef.name ?? (withDefaults ? modelDef.id : undefined),
api,
provider: providerName,
baseUrl: modelDef.baseUrl ?? providerBaseUrl,
reasoning: modelDef.reasoning ?? (withDefaults ? false : undefined),
thinking: modelDef.thinking as ThinkingConfig | undefined,
input: input as ("text" | "image")[],
cost,
contextWindow: modelDef.contextWindow ?? (withDefaults ? 128000 : undefined),
@@ -462,7 +491,7 @@ function buildCustomModel(
compat: modelDef.compat,
contextPromotionTarget: modelDef.contextPromotionTarget,
premiumMultiplier: modelDef.premiumMultiplier,
} as Model<Api>;
} as Model<Api>);
}
/**
@@ -855,19 +884,21 @@ export class ModelRegistry {
for (const item of models) {
const id = item.model || item.name;
if (!id) continue;
discovered.push({
id,
name: item.name || id,
api: providerConfig.api,
provider: providerConfig.provider,
baseUrl: `${endpoint}/v1`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
headers: providerConfig.headers,
});
discovered.push(
enrichModelThinking({
id,
name: item.name || id,
api: providerConfig.api,
provider: providerConfig.provider,
baseUrl: `${endpoint}/v1`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
headers: providerConfig.headers,
}),
);
}
return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
} catch (error) {
@@ -909,24 +940,26 @@ export class ModelRegistry {
for (const item of models) {
const id = item.id;
if (!id) continue;
discovered.push({
id,
name: id,
api: providerConfig.api,
provider: providerConfig.provider,
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
headers,
compat: {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
},
});
discovered.push(
enrichModelThinking({
id,
name: id,
api: providerConfig.api,
provider: providerConfig.provider,
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
headers,
compat: {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
},
}),
);
}
return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
} catch (error) {
@@ -1008,7 +1041,7 @@ export class ModelRegistry {
providerConfig.headers,
providerConfig.apiKey,
providerConfig.authHeader,
modelDef,
modelDef as CustomModelDefinitionLike,
{ useDefaults: true },
);
if (!model) continue;
@@ -1161,7 +1194,7 @@ export class ModelRegistry {
config.headers,
config.apiKey,
config.authHeader,
modelDef,
modelDef as CustomModelDefinitionLike,
{ useDefaults: false },
);
if (!model) {
@@ -1218,6 +1251,7 @@ export interface ProviderConfigInput {
api?: Api;
baseUrl?: string;
reasoning: boolean;
thinking?: ThinkingConfig;
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
@@ -1,17 +1,20 @@
/**
* Model resolution, scoping, and initial selection
*/
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import {
type Api,
clampThinkingLevelForModel,
DEFAULT_MODEL_PER_PROVIDER,
type Effort,
type KnownProvider,
type Model,
modelsAreEqual,
parseThinkingLevel,
type ThinkingLevel,
} from "@oh-my-pi/pi-ai";
import chalk from "chalk";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
import { fuzzyMatch } from "../utils/fuzzy";
import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
import type { Settings } from "./settings";
@@ -377,7 +380,14 @@ export function resolveModelRoleValue(
options?.matchPreferences,
);
return { model, thinkingLevel, explicitThinkingLevel, warning };
return {
model,
thinkingLevel: explicitThinkingLevel
? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel)
: thinkingLevel,
explicitThinkingLevel,
warning,
};
}
export function extractExplicitThinkingSelector(
@@ -393,10 +403,10 @@ export function extractExplicitThinkingSelector(
while (!visited.has(current)) {
visited.add(current);
const lastColonIndex = current.lastIndexOf(":");
const hasThinkingSuffix =
lastColonIndex > PREFIX_MODEL_ROLE.length && parseThinkingLevel(current.slice(lastColonIndex + 1));
if (hasThinkingSuffix) {
return current.slice(lastColonIndex + 1) as ThinkingLevel;
const thinkingSelector =
lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(current.slice(lastColonIndex + 1)) : undefined;
if (thinkingSelector) {
return thinkingSelector;
}
const expanded = expandRoleAlias(current, settings).trim();
if (!expanded || expanded === current) break;
@@ -520,7 +530,13 @@ export async function resolveModelScope(
for (const model of matchingModels) {
if (!scopedModels.find(sm => modelsAreEqual(sm.model, model))) {
scopedModels.push({ model, thinkingLevel, explicitThinkingLevel });
scopedModels.push({
model,
thinkingLevel: explicitThinkingLevel
? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel)
: thinkingLevel,
explicitThinkingLevel,
});
}
}
continue;
@@ -543,7 +559,13 @@ export async function resolveModelScope(
// Avoid duplicates
if (!scopedModels.find(sm => modelsAreEqual(sm.model, model))) {
scopedModels.push({ model, thinkingLevel, explicitThinkingLevel });
scopedModels.push({
model,
thinkingLevel: explicitThinkingLevel
? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel)
: thinkingLevel,
explicitThinkingLevel,
});
}
}
@@ -644,7 +666,7 @@ export function resolveCliModel(options: {
export interface InitialModelResult {
model: Model<Api> | undefined;
thinkingLevel: ThinkingLevel;
thinkingLevel?: ThinkingLevel;
fallbackMessage: string | undefined;
}
@@ -663,7 +685,7 @@ export async function findInitialModel(options: {
isContinuing: boolean;
defaultProvider?: string;
defaultModelId?: string;
defaultThinkingSelector?: ThinkingLevel;
defaultThinkingSelector?: Effort;
modelRegistry: ModelRegistry;
}): Promise<InitialModelResult> {
const {
@@ -678,7 +700,7 @@ export async function findInitialModel(options: {
} = options;
let model: Model<Api> | undefined;
let thinkingLevel: ThinkingLevel = "off";
let thinkingLevel: Effort | undefined;
// 1. CLI args take priority
if (cliProvider && cliModel) {
@@ -687,16 +709,22 @@ export async function findInitialModel(options: {
console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
process.exit(1);
}
return { model: found, thinkingLevel: "off", fallbackMessage: undefined };
return { model: found, thinkingLevel: undefined, fallbackMessage: undefined };
}
// 2. Use first model from scoped models (skip if continuing/resuming)
if (scopedModels.length > 0 && !isContinuing) {
const scoped = scopedModels[0];
const scopedThinkingSelector = scoped.thinkingLevel ?? defaultThinkingSelector ?? "off";
const scopedThinkingSelector =
scoped.thinkingLevel === ThinkingLevel.Inherit
? defaultThinkingSelector
: (scoped.thinkingLevel ?? defaultThinkingSelector);
return {
model: scoped.model,
thinkingLevel: scopedThinkingSelector,
thinkingLevel:
scopedThinkingSelector === ThinkingLevel.Off
? ThinkingLevel.Off
: clampThinkingLevelForModel(scoped.model, scopedThinkingSelector),
fallbackMessage: undefined,
};
}
@@ -706,9 +734,7 @@ export async function findInitialModel(options: {
const found = modelRegistry.find(defaultProvider, defaultModelId);
if (found) {
model = found;
if (defaultThinkingSelector) {
thinkingLevel = defaultThinkingSelector;
}
thinkingLevel = clampThinkingLevelForModel(found, defaultThinkingSelector);
return { model, thinkingLevel, fallbackMessage: undefined };
}
}
@@ -722,16 +748,16 @@ export async function findInitialModel(options: {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find(m => m.provider === provider && m.id === defaultId);
if (match) {
return { model: match, thinkingLevel: "off", fallbackMessage: undefined };
return { model: match, thinkingLevel: undefined, fallbackMessage: undefined };
}
}
// If no default found, use first available
return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: undefined };
return { model: availableModels[0], thinkingLevel: undefined, fallbackMessage: undefined };
}
// 5. No model found
return { model: undefined, thinkingLevel: "off", fallbackMessage: undefined };
return { model: undefined, thinkingLevel: undefined, fallbackMessage: undefined };
}
/**
@@ -1,4 +1,4 @@
import { getAvailableThinkingLevels } from "@oh-my-pi/pi-ai";
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
/** Unified settings schema - single source of truth for all settings.
* Unified settings schema - single source of truth for all settings.
@@ -192,7 +192,7 @@ export const SETTINGS_SCHEMA = {
},
defaultThinkingLevel: {
type: "enum",
values: getAvailableThinkingLevels(),
values: THINKING_EFFORTS,
default: "high",
ui: {
tab: "agent",
@@ -1,13 +1,13 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ThinkingLevel } from "@oh-my-pi/pi-ai";
import { parseThinkingLevel } from "@oh-my-pi/pi-ai";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { FileType, glob } from "@oh-my-pi/pi-natives";
import { CONFIG_DIR_NAME, tryParseJson } from "@oh-my-pi/pi-utils";
import { readFile } from "../capability/fs";
import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
import type { Skill, SkillFrontmatter } from "../capability/skill";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
import { parseThinkingLevel } from "../thinking";
import { parseFrontmatter } from "../utils/frontmatter";
/**
@@ -4,7 +4,8 @@
import type * as fs1 from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { ImageContent, Model, TextContent, ThinkingLevel } from "@oh-my-pi/pi-ai";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Model, TextContent } from "@oh-my-pi/pi-ai";
import * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
import type { KeyId } from "@oh-my-pi/pi-tui";
import { hasFsCode, isEacces, isEnoent, logger } from "@oh-my-pi/pi-utils";
@@ -214,7 +215,7 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
return this.runtime.setModel(model);
}
getThinkingLevel(): ThinkingLevel {
getThinkingLevel(): ThinkingLevel | undefined {
return this.runtime.getThinkingLevel();
}
@@ -7,7 +7,7 @@
* - Register commands, keyboard shortcuts, and CLI flags
* - Interact with the user via UI primitives
*/
import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type {
Api,
AssistantMessageEvent,
@@ -19,7 +19,6 @@ import type {
OAuthLoginCallbacks,
SimpleStreamOptions,
TextContent,
ThinkingLevel,
ToolResultMessage,
} from "@oh-my-pi/pi-ai";
import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
@@ -1058,9 +1057,9 @@ export interface ExtensionAPI {
setModel(model: Model): Promise<boolean>;
/** Get current thinking level. */
getThinkingLevel(): ThinkingLevel;
getThinkingLevel(): ThinkingLevel | undefined;
/** Set thinking level (clamped to model capabilities). */
/** Set thinking level for the current session. */
setThinkingLevel(level: ThinkingLevel): void;
// =========================================================================
@@ -1086,11 +1085,11 @@ export interface ExtensionAPI {
* id: "claude-sonnet-4@20250514",
* name: "Claude Sonnet 4 (Vertex)",
* reasoning: true,
* thinking: { mode: "anthropic-adaptive", minLevel: "minimal", maxLevel: "high" },
* input: ["text", "image"],
* cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
* contextWindow: 200000,
* maxTokens: 64000,
* }
* ]
* });
*
@@ -1149,8 +1148,10 @@ export interface ProviderModelConfig {
name: string;
/** API type override for this model. */
api?: Api;
/** Whether the model supports extended thinking. */
/** Whether the model supports extended thinking at all. */
reasoning: boolean;
/** Optional canonical thinking capability metadata for per-model effort support. */
thinking?: Model["thinking"];
/** Supported input types. */
input: ("text" | "image")[];
/** Cost per million tokens. */
@@ -1218,7 +1219,7 @@ export type SetActiveToolsHandler = (toolNames: string[]) => Promise<void>;
export type SetModelHandler = (model: Model) => Promise<boolean>;
export type GetThinkingLevelHandler = () => ThinkingLevel;
export type GetThinkingLevelHandler = () => ThinkingLevel | undefined;
export type SetThinkingLevelHandler = (level: ThinkingLevel, persist?: boolean) => void;
+5 -22
View File
@@ -10,7 +10,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { createInterface } from "node:readline/promises";
import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import type { Args } from "./cli/args";
@@ -334,11 +334,10 @@ async function buildSessionOptions(
scopedModels: ScopedModel[],
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
): Promise<{ options: CreateAgentSessionOptions; cliThinkingFromModel: boolean }> {
): Promise<{ options: CreateAgentSessionOptions }> {
const options: CreateAgentSessionOptions = {
cwd: parsed.cwd ?? getProjectDir(),
};
let cliThinkingFromModel = false;
// Auto-discover SYSTEM.md if no CLI system prompt provided
const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
@@ -380,7 +379,6 @@ async function buildSessionOptions(
settings.overrideModelRoles({ default: `${resolved.model.provider}/${resolved.model.id}` });
if (!parsed.thinking && resolved.thinkingLevel) {
options.thinkingLevel = resolved.thinkingLevel;
cliThinkingFromModel = true;
}
}
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
@@ -483,7 +481,7 @@ async function buildSessionOptions(
options.additionalExtensionPaths = [];
}
return { options, cliThinkingFromModel };
return { options };
}
export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<void> {
@@ -618,7 +616,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
sessionManager = await SessionManager.open(selectedPath);
}
const { options: sessionOptions, cliThinkingFromModel } = await logger.timeAsync("buildSessionOptions", () =>
const { options: sessionOptions } = await logger.timeAsync("buildSessionOptions", () =>
buildSessionOptions(parsedArgs, scopedModels, sessionManager, modelRegistry),
);
sessionOptions.authStorage = authStorage;
@@ -692,21 +690,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
process.exit(1);
}
// Clamp thinking level to model capabilities for CLI-provided thinking levels.
// This covers both --thinking <level> and --model <pattern>:<thinking>.
const cliThinkingOverride = parsedArgs.thinking !== undefined || cliThinkingFromModel;
if (session.model && cliThinkingOverride) {
let effectiveThinking = session.thinkingLevel;
if (!session.model.reasoning) {
effectiveThinking = "off";
} else if (effectiveThinking === "xhigh" && !supportsXhigh(session.model)) {
effectiveThinking = "high";
}
if (effectiveThinking !== session.thinkingLevel) {
session.setThinkingLevel(effectiveThinking);
}
}
if (mode === "rpc") {
await runRpcMode(session);
} else if (isInteractive) {
@@ -717,7 +700,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
if (scopedModelsForDisplay.length > 0) {
const modelList = scopedModelsForDisplay
.map(scopedModel => {
const thinkingStr = scopedModel.thinkingLevel !== "off" ? `:${scopedModel.thinkingLevel}` : "";
const thinkingStr = !scopedModel.thinkingLevel ? `:${scopedModel.thinkingLevel}` : "";
return `${scopedModel.model.id}${thinkingStr}`;
})
.join(", ");
+7 -3
View File
@@ -3,7 +3,7 @@ import type * as fsNode from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { completeSimple, type Model } from "@oh-my-pi/pi-ai";
import { completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
import { getAgentDbPath, logger, parseJsonlLenient } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { parseModelString } from "../config/model-resolver";
@@ -583,7 +583,11 @@ async function runStage1Job(options: {
systemPrompt: stageOneSystemTemplate,
messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }],
},
{ apiKey, maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))), reasoning: "low" },
{
apiKey,
maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))),
reasoning: Effort.Low,
},
);
if (response.stopReason === "error") {
@@ -709,7 +713,7 @@ async function runConsolidationModel(options: { memoryRoot: string; model: Model
{
messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
},
{ apiKey, maxTokens: 8192, reasoning: "medium" },
{ apiKey, maxTokens: 8192, reasoning: Effort.Medium },
);
if (response.stopReason === "error") {
throw new Error(response.errorMessage || "phase2 model error");
@@ -1,4 +1,5 @@
import * as fs from "node:fs";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
import { theme } from "../../modes/theme/theme";
@@ -212,11 +213,11 @@ export class FooterComponent implements Component {
// Add model name on the right side, plus thinking level if model supports it
const modelName = state.model?.id || "no-model";
// Add thinking level hint if model supports reasoning and thinking is enabled
// Add thinking level hint when the current model advertises supported efforts
let rightSide = modelName;
if (state.model?.reasoning) {
const thinkingLevel = state.thinkingLevel || "off";
if (thinkingLevel !== "off") {
if (state.model?.thinking) {
const thinkingLevel = state.thinkingLevel ?? ThinkingLevel.Off;
if (thinkingLevel !== ThinkingLevel.Off) {
rightSide = `${modelName} • ${thinkingLevel}`;
}
}
@@ -1,16 +1,11 @@
import {
getAvailableThinkingLevels,
getThinkingMetadata,
type Model,
modelsAreEqual,
supportsXhigh,
type ThinkingMode,
} from "@oh-my-pi/pi-ai";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
import { Container, Input, matchesKey, Spacer, type Tab, TabBar, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry";
import { resolveModelRoleValue } from "../../config/model-resolver";
import type { Settings } from "../../config/settings";
import { type ThemeColor, theme } from "../../modes/theme/theme";
import { getThinkingLevelMetadata } from "../../thinking";
import { fuzzyFilter } from "../../utils/fuzzy";
import { getTabBarTheme } from "../shared";
import { DynamicBorder } from "./dynamic-border";
@@ -29,15 +24,15 @@ interface ModelItem {
interface ScopedModelItem {
model: Model;
thinkingLevel: string;
thinkingLevel?: string;
}
interface RoleAssignment {
model: Model;
thinkingMode: ThinkingMode;
thinkingLevel: ThinkingLevel;
}
type RoleSelectCallback = (model: Model, role: ModelRole | null, thinkingMode?: ThinkingMode) => void;
type RoleSelectCallback = (model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel) => void;
type CancelCallback = () => void;
interface MenuRoleAction {
label: string;
@@ -97,7 +92,7 @@ export class ModelSelectorComponent extends Container {
settings: Settings,
modelRegistry: ModelRegistry,
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model, role: ModelRole | null, thinkingMode?: ThinkingMode) => void,
onSelect: (model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel) => void,
onCancel: () => void,
options?: { temporaryOnly?: boolean; initialSearchInput?: string },
) {
@@ -192,7 +187,8 @@ export class ModelSelectorComponent extends Container {
if (model) {
this.#roles[role] = {
model,
thinkingMode: explicitThinkingLevel && thinkingLevel !== undefined ? thinkingLevel : "inherit",
thinkingLevel:
explicitThinkingLevel && thinkingLevel !== undefined ? thinkingLevel : ThinkingLevel.Inherit,
};
}
}
@@ -409,7 +405,7 @@ export class ModelSelectorComponent extends Container {
if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
const badge = makeInvertedBadge(tag, color ?? "success");
const thinkingLabel = getThinkingMetadata(assigned.thinkingMode).label;
const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
}
const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : "";
@@ -456,19 +452,18 @@ export class ModelSelectorComponent extends Container {
this.#listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
}
}
#getThinkingModesForModel(model: Model): ReadonlyArray<ThinkingMode> {
return ["inherit", ...getAvailableThinkingLevels(supportsXhigh(model))];
#getThinkingLevelsForModel(model: Model): ReadonlyArray<ThinkingLevel> {
return [ThinkingLevel.Inherit, ThinkingLevel.Off, ...getSupportedEfforts(model)];
}
#getCurrentRoleThinkingMode(role: ModelRole): ThinkingMode {
return this.#roles[role]?.thinkingMode ?? "inherit";
#getCurrentRoleThinkingLevel(role: ModelRole): ThinkingLevel {
return this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
}
#getThinkingPreselectIndex(role: ModelRole, model: Model): number {
const options = this.#getThinkingModesForModel(model);
const currentMode = this.#getCurrentRoleThinkingMode(role);
const preferredMode = currentMode === "xhigh" && !options.includes("xhigh") ? "high" : currentMode;
const foundIndex = options.indexOf(preferredMode);
const options = this.#getThinkingLevelsForModel(model);
const currentLevel = this.#getCurrentRoleThinkingLevel(role);
const foundIndex = options.indexOf(currentLevel);
return foundIndex >= 0 ? foundIndex : 0;
}
@@ -496,11 +491,11 @@ export class ModelSelectorComponent extends Container {
if (!selectedModel) return;
const showingThinking = this.#menuStep === "thinking" && this.#menuSelectedRole !== null;
const thinkingOptions = showingThinking ? this.#getThinkingModesForModel(selectedModel.model) : [];
const thinkingOptions = showingThinking ? this.#getThinkingLevelsForModel(selectedModel.model) : [];
const optionLines = showingThinking
? thinkingOptions.map((thinkingMode, index) => {
? thinkingOptions.map((thinkingLevel, index) => {
const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " ";
const label = getThinkingMetadata(thinkingMode).label;
const label = getThinkingLevelMetadata(thinkingLevel).label;
return `${prefix}${label}`;
})
: MENU_ROLE_ACTIONS.map((action, index) => {
@@ -607,7 +602,7 @@ export class ModelSelectorComponent extends Container {
const optionCount =
this.#menuStep === "thinking" && this.#menuSelectedRole !== null
? this.#getThinkingModesForModel(selectedModel.model).length
? this.#getThinkingLevelsForModel(selectedModel.model).length
: MENU_ROLE_ACTIONS.length;
if (optionCount === 0) return;
@@ -635,10 +630,10 @@ export class ModelSelectorComponent extends Container {
}
if (!this.#menuSelectedRole) return;
const thinkingOptions = this.#getThinkingModesForModel(selectedModel.model);
const thinkingMode = thinkingOptions[this.#menuSelectedIndex];
if (!thinkingMode) return;
this.#handleSelect(selectedModel.model, this.#menuSelectedRole, thinkingMode);
const thinkingOptions = this.#getThinkingLevelsForModel(selectedModel.model);
const thinkingLevel = thinkingOptions[this.#menuSelectedIndex];
if (!thinkingLevel) return;
this.#handleSelect(selectedModel.model, this.#menuSelectedRole, thinkingLevel);
this.#closeMenu();
return;
}
@@ -657,28 +652,28 @@ export class ModelSelectorComponent extends Container {
}
}
#formatRoleModelValue(model: Model, thinkingMode: ThinkingMode): string {
#formatRoleModelValue(model: Model, thinkingLevel: ThinkingLevel): string {
const modelKey = `${model.provider}/${model.id}`;
if (thinkingMode === "inherit") return modelKey;
return `${modelKey}:${thinkingMode}`;
if (thinkingLevel === ThinkingLevel.Inherit) return modelKey;
return `${modelKey}:${thinkingLevel}`;
}
#handleSelect(model: Model, role: ModelRole | null, thinkingMode?: ThinkingMode): void {
#handleSelect(model: Model, role: ModelRole | null, thinkingLevel?: ThinkingLevel): void {
// For temporary role, don't save to settings - just notify caller
if (role === null) {
this.#onSelectCallback(model, null);
return;
}
const selectedThinkingMode = thinkingMode ?? this.#getCurrentRoleThinkingMode(role);
const selectedThinkingLevel = thinkingLevel ?? this.#getCurrentRoleThinkingLevel(role);
// Save to settings
this.#settings.setModelRole(role, this.#formatRoleModelValue(model, selectedThinkingMode));
this.#settings.setModelRole(role, this.#formatRoleModelValue(model, selectedThinkingLevel));
// Update local state for UI
this.#roles[role] = { model, thinkingMode: selectedThinkingMode };
this.#roles[role] = { model, thinkingLevel: selectedThinkingLevel };
// Notify caller (for updating agent state if needed)
this.#onSelectCallback(model, role, selectedThinkingMode);
this.#onSelectCallback(model, role, selectedThinkingLevel);
// Update list to show new badges
this.#updateList();
@@ -7,7 +7,7 @@
* 2. That's it - it appears in the UI automatically
*/
import { getAvailableThinkingLevels, getThinkingMetadata } from "@oh-my-pi/pi-ai";
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { TERMINAL } from "@oh-my-pi/pi-tui";
import {
getDefault,
@@ -19,6 +19,7 @@ import {
type SettingPath,
type SettingTab,
} from "../../config/settings-schema";
import { getThinkingLevelMetadata } from "../../thinking";
// ═══════════════════════════════════════════════════════════════════════════
// UI Definition Types
@@ -251,7 +252,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
{ value: "on", label: "On", description: "Force websockets for OpenAI Codex models" },
],
// Default thinking level
defaultThinkingLevel: [...getAvailableThinkingLevels().map(getThinkingMetadata)],
defaultThinkingLevel: [...THINKING_EFFORTS.map(getThinkingLevelMetadata)],
// Temperature
temperature: [
{ value: "-1", label: "Default", description: "Use provider default" },
@@ -1,4 +1,5 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-ai";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Effort } from "@oh-my-pi/pi-ai";
import {
Container,
matchesKey,
@@ -134,9 +135,9 @@ function getSettingsTabs(): Tab[] {
*/
export interface SettingsRuntimeContext {
/** Available thinking levels (from session) */
availableThinkingLevels: ThinkingLevel[];
availableThinkingLevels: Effort[];
/** Current thinking level (from session) */
thinkingLevel: ThinkingLevel;
thinkingLevel: ThinkingLevel | undefined;
/** Available themes */
availableThemes: string[];
/** Working directory for plugins tab */
@@ -1,4 +1,5 @@
import * as os from "node:os";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { TERMINAL } from "@oh-my-pi/pi-tui";
import { formatDuration, formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
import { theme } from "../../../modes/theme/theme";
@@ -50,9 +51,9 @@ const modelSegment: StatusLineSegment = {
}
// Add thinking level with dot separator
if (opts.showThinkingLevel !== false && state.model?.reasoning) {
const level = state.thinkingLevel || "off";
if (level !== "off") {
if (opts.showThinkingLevel !== false && state.model?.thinking) {
const level = state.thinkingLevel ?? ThinkingLevel.Off;
if (level !== ThinkingLevel.Off) {
const thinkingText = theme.thinking[level as keyof typeof theme.thinking];
if (thinkingText) {
content += `${theme.sep.dot}${thinkingText}`;
@@ -1,7 +1,7 @@
import { getThinkingMetadata, type ThinkingLevel } from "@oh-my-pi/pi-ai";
import type { Effort } from "@oh-my-pi/pi-ai";
import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../../modes/theme/theme";
import { getThinkingLevelMetadata } from "../../thinking";
import { DynamicBorder } from "./dynamic-border";
/**
@@ -11,14 +11,14 @@ export class ThinkingSelectorComponent extends Container {
#selectList: SelectList;
constructor(
currentLevel: ThinkingLevel,
availableLevels: ThinkingLevel[],
onSelect: (level: ThinkingLevel) => void,
currentLevel: Effort,
availableLevels: Effort[],
onSelect: (level: Effort) => void,
onCancel: () => void,
) {
super();
const thinkingLevels: SelectItem[] = availableLevels.map(getThinkingMetadata);
const thinkingLevels: SelectItem[] = availableLevels.map(getThinkingLevelMetadata);
// Add top border
this.addChild(new DynamicBorder());
@@ -33,7 +33,7 @@ export class ThinkingSelectorComponent extends Container {
}
this.#selectList.onSelect = item => {
onSelect(item.value as ThinkingLevel);
onSelect(item.value as Effort);
};
this.#selectList.onCancel = () => {
@@ -1,3 +1,4 @@
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import {
type Component,
Container,
@@ -382,7 +383,7 @@ class TreeList implements Component {
parts.push("model", entry.model);
break;
case "thinking_level_change":
parts.push("thinking", entry.thinkingLevel);
parts.push("thinking", entry.thinkingLevel ?? ThinkingLevel.Off);
break;
case "custom":
parts.push("custom", entry.customType);
@@ -585,7 +586,7 @@ class TreeList implements Component {
result = theme.fg("dim", `[model: ${entry.model}]`);
break;
case "thinking_level_change":
result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`);
result = theme.fg("dim", `[thinking: ${entry.thinkingLevel ?? ThinkingLevel.Off}]`);
break;
case "custom":
result = theme.fg("dim", `[custom: ${entry.customType}]`);
@@ -1,5 +1,5 @@
import * as fs from "node:fs/promises";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { copyToClipboard, readImageFromClipboard, sanitizeText } from "@oh-my-pi/pi-natives";
import { $env } from "@oh-my-pi/pi-utils";
import { settings } from "../../config/settings";
@@ -544,7 +544,9 @@ export class InputController {
const roleLabel = result.role === "default" ? "default" : result.role;
const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel));
const thinkingStr =
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
result.model.thinking && result.thinkingLevel !== ThinkingLevel.Off
? ` (thinking: ${result.thinkingLevel})`
: "";
const tempLabel = options?.temporary ? " (temporary)" : "";
const cycleSeparator = theme.fg("dim", " > ");
const cycleLabel = roleOrder
@@ -1,4 +1,5 @@
import { getOAuthProviders, type OAuthProvider, type ThinkingLevel } from "@oh-my-pi/pi-ai";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
@@ -380,7 +381,7 @@ export class SelectorController {
this.ctx.settings,
this.ctx.session.modelRegistry,
this.ctx.session.scopedModels,
async (model, role, thinkingMode) => {
async (model, role, thinkingLevel) => {
try {
if (role === null) {
// Temporary: update agent state but don't persist to settings
@@ -393,8 +394,8 @@ export class SelectorController {
} else if (role === "default") {
// Default: update agent state and persist
await this.ctx.session.setModel(model, role);
if (thinkingMode && thinkingMode !== "inherit") {
this.ctx.session.setThinkingLevel(thinkingMode as ThinkingLevel);
if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
this.ctx.session.setThinkingLevel(thinkingLevel);
}
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
@@ -3,7 +3,7 @@
* Handles TUI rendering and user interaction, delegating business logic to AgentSession.
*/
import * as path from "node:path";
import type { Agent, AgentMessage } from "@oh-my-pi/pi-agent-core";
import { type Agent, type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
import type { Component, Loader, SlashCommand } from "@oh-my-pi/pi-tui";
import {
@@ -423,7 +423,7 @@ export class InteractiveMode implements InteractiveModeContext {
} else if (this.isPythonMode) {
this.editor.borderColor = theme.getPythonModeBorderColor();
} else {
const level = this.session.thinkingLevel || "off";
const level = this.session.thinkingLevel ?? ThinkingLevel.Off;
this.editor.borderColor = theme.getThinkingBorderColor(level);
}
this.updateEditorTopBorder();
@@ -3,8 +3,8 @@
*
* Spawns the agent in RPC mode and provides a typed API for all operations.
*/
import type { AgentEvent, AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, ThinkingLevel } from "@oh-my-pi/pi-ai";
import type { AgentEvent, AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai";
import { isRecord, ptree, readJsonl } from "@oh-my-pi/pi-utils";
import type { BashResult } from "../../exec/bash-executor";
import type { SessionStats } from "../../session/agent-session";
@@ -34,12 +34,7 @@ export interface RpcClientOptions {
args?: string[];
}
export interface ModelInfo {
provider: string;
id: string;
contextWindow: number;
reasoning: boolean;
}
export type ModelInfo = Pick<Model, "provider" | "id" | "contextWindow" | "reasoning" | "thinking">;
export type RpcEventListener = (event: AgentEvent) => void;
@@ -284,7 +279,7 @@ export class RpcClient {
*/
async cycleModel(): Promise<{
model: { provider: string; id: string };
thinkingLevel: ThinkingLevel;
thinkingLevel: ThinkingLevel | undefined;
isScoped: boolean;
} | null> {
const response = await this.#send({ type: "cycle_model" });
@@ -309,7 +304,7 @@ export class RpcClient {
/**
* Cycle thinking level.
*/
async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> {
async cycleThinkingLevel(): Promise<{ level: Effort } | null> {
const response = await this.#send({ type: "cycle_thinking_level" });
return this.#getData(response);
}
@@ -4,8 +4,8 @@
* Commands are sent as JSON lines on stdin.
* Responses and events are emitted as JSON lines on stdout.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Model, ThinkingLevel } from "@oh-my-pi/pi-ai";
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai";
import type { BashResult } from "../../exec/bash-executor";
import type { SessionStats } from "../../session/agent-session";
import type { CompactionResult } from "../../session/compaction";
@@ -70,7 +70,7 @@ export type RpcCommand =
export interface RpcSessionState {
model?: Model;
thinkingLevel: ThinkingLevel;
thinkingLevel: ThinkingLevel | undefined;
isStreaming: boolean;
isCompacting: boolean;
steeringMode: "all" | "one-at-a-time";
@@ -114,7 +114,7 @@ export type RpcResponse =
type: "response";
command: "cycle_model";
success: true;
data: { model: Model; thinkingLevel: ThinkingLevel; isScoped: boolean } | null;
data: { model: Model; thinkingLevel: ThinkingLevel | undefined; isScoped: boolean } | null;
}
| {
id?: string;
@@ -131,7 +131,7 @@ export type RpcResponse =
type: "response";
command: "cycle_thinking_level";
success: true;
data: { level: ThinkingLevel } | null;
data: { level: Effort } | null;
}
// Queue modes
@@ -1,6 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ThinkingLevel } from "@oh-my-pi/pi-ai";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Effort } from "@oh-my-pi/pi-ai";
import {
detectMacOSAppearance,
type HighlightColors as NativeHighlightColors,
@@ -1223,7 +1224,7 @@ export class Theme {
return this.mode;
}
getThinkingBorderColor(level: ThinkingLevel): (str: string) => string {
getThinkingBorderColor(level: ThinkingLevel | Effort): (str: string) => string {
// Map thinking levels to dedicated theme colors
switch (level) {
case "off":
@@ -1 +1 @@
Threshold-triggered maintenance: preserve critical implementation state and immediate next actions.
Threshold-triggered maintenance: preserve critical implementation state and immediate next actions.
+18 -13
View File
@@ -1,5 +1,12 @@
import { Agent, type AgentEvent, type AgentMessage, type AgentTool, INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
import { type Message, type Model, supportsXhigh, type ThinkingLevel } from "@oh-my-pi/pi-ai";
import {
Agent,
type AgentEvent,
type AgentMessage,
type AgentTool,
INTENT_FIELD,
type ThinkingLevel,
} from "@oh-my-pi/pi-agent-core";
import type { Message, Model } from "@oh-my-pi/pi-ai";
import { prewarmOpenAICodexResponses } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
import type { Component } from "@oh-my-pi/pi-tui";
@@ -72,6 +79,7 @@ import {
loadProjectContextFiles as loadContextFilesInternal,
} from "./system-prompt";
import { AgentOutputManager } from "./task/output-manager";
import { resolveThinkingLevelForModel, toReasoningEffort } from "./thinking";
import {
BashTool,
BUILTIN_TOOLS,
@@ -117,10 +125,10 @@ export interface CreateAgentSessionOptions {
/** Raw model pattern string (e.g. from --model CLI flag) to resolve after extensions load.
* Used when model lookup is deferred because extension-provided models aren't registered yet. */
modelPattern?: string;
/** Thinking level. Default: from settings, else 'off' (clamped to model capabilities) */
/** Thinking selector. Default: from settings, else unset */
thinkingLevel?: ThinkingLevel;
/** Models available for cycling (Ctrl+P in interactive mode) */
scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
/** System prompt. String replaces default, function receives default and returns final. */
systemPrompt?: string | ((defaultPrompt: string) => string);
@@ -697,7 +705,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// If session has data and includes a thinking entry, restore it
if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) {
thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
thinkingLevel = existingSession.thinkingLevel as ThinkingLevel | undefined;
}
if (thinkingLevel === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) {
@@ -706,14 +714,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Fall back to settings default
if (thinkingLevel === undefined) {
thinkingLevel = settings.get("defaultThinkingLevel") ?? "off";
thinkingLevel = settings.get("defaultThinkingLevel");
}
// Clamp to model capabilities
if (!model || !model.reasoning) {
thinkingLevel = "off";
} else if (thinkingLevel === "xhigh" && !supportsXhigh(model)) {
thinkingLevel = "high";
if (model) {
thinkingLevel = resolveThinkingLevelForModel(model, thinkingLevel);
}
let skills: Skill[];
@@ -1350,7 +1354,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
initialState: {
systemPrompt,
model,
thinkingLevel,
thinkingLevel: toReasoningEffort(thinkingLevel),
tools: initialTools,
},
convertToLlm: convertToLlmFinal,
@@ -1421,6 +1425,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
session = new AgentSession({
agent,
thinkingLevel,
sessionManager,
settings,
scopedModels: options.scopedModels,
@@ -24,16 +24,17 @@ import {
type AgentState,
type AgentTool,
INTENT_FIELD,
ThinkingLevel,
} from "@oh-my-pi/pi-agent-core";
import type {
AssistantMessage,
Effort,
ImageContent,
Message,
Model,
ProviderSessionState,
ServiceTier,
TextContent,
ThinkingLevel,
ToolCall,
ToolChoice,
Usage,
@@ -41,11 +42,10 @@ import type {
} from "@oh-my-pi/pi-ai";
import {
calculateRateLimitBackoffMs,
getAvailableThinkingLevels,
getSupportedEfforts,
isContextOverflow,
modelsAreEqual,
parseRateLimitReason,
supportsXhigh,
} from "@oh-my-pi/pi-ai";
import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { AsyncJob, AsyncJobManager } from "../async";
@@ -96,6 +96,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
};
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import type { SecretObfuscator } from "../secrets/obfuscator";
import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
import type { CheckpointState } from "../tools/checkpoint";
import { outputMeta } from "../tools/output-meta";
import { resolveToCwd } from "../tools/path-utils";
@@ -166,7 +167,9 @@ export interface AgentSessionConfig {
/** Async background jobs launched by tools */
asyncJobManager?: AsyncJobManager;
/** Models to cycle through with Ctrl+P (from --models flag) */
scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
/** Initial session thinking selector. */
thinkingLevel?: ThinkingLevel;
/** Prompt templates for expansion */
promptTemplates?: PromptTemplate[];
/** File-based slash commands for expansion */
@@ -215,7 +218,7 @@ export interface PromptOptions {
/** Result from cycleModel() */
export interface ModelCycleResult {
model: Model;
thinkingLevel: ThinkingLevel;
thinkingLevel: ThinkingLevel | undefined;
/** Whether cycling through scoped models (--models flag) or all available */
isScoped: boolean;
}
@@ -223,7 +226,7 @@ export interface ModelCycleResult {
/** Result from cycleRoleModels() */
export interface RoleModelCycleResult {
model: Model;
thinkingLevel: ThinkingLevel;
thinkingLevel: ThinkingLevel | undefined;
role: ModelRole;
}
@@ -305,7 +308,8 @@ export class AgentSession {
readonly settings: Settings;
#asyncJobManager: AsyncJobManager | undefined = undefined;
#scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
#scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
#thinkingLevel: ThinkingLevel | undefined;
#promptTemplates: PromptTemplate[];
#slashCommands: FileSlashCommand[];
@@ -406,6 +410,7 @@ export class AgentSession {
this.settings = config.settings;
this.#asyncJobManager = config.asyncJobManager;
this.#scopedModels = config.scopedModels ?? [];
this.#thinkingLevel = config.thinkingLevel;
this.#promptTemplates = config.promptTemplates ?? [];
this.#slashCommands = config.slashCommands ?? [];
this.#extensionRunner = config.extensionRunner;
@@ -1544,8 +1549,8 @@ export class AgentSession {
}
/** Current thinking level */
get thinkingLevel(): ThinkingLevel {
return this.agent.state.thinkingLevel;
get thinkingLevel(): ThinkingLevel | undefined {
return this.#thinkingLevel;
}
get serviceTier(): ServiceTier | undefined {
@@ -1724,7 +1729,7 @@ export class AgentSession {
}
/** Scoped models for cycling (from --models flag) */
get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel: ThinkingLevel }> {
get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> {
return this.#scopedModels;
}
@@ -2654,7 +2659,7 @@ export class AgentSession {
this.settings.setModelRole(role, this.#formatRoleModelValue(role, model));
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
// Re-clamp thinking level for new model's capabilities without persisting settings
// Re-apply the current thinking level for the newly selected model
this.setThinkingLevel(this.thinkingLevel);
}
@@ -2673,7 +2678,7 @@ export class AgentSession {
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
// Re-clamp thinking level for new model's capabilities without persisting settings
// Re-apply the current thinking level for the newly selected model
this.setThinkingLevel(this.thinkingLevel);
}
@@ -2758,9 +2763,9 @@ export class AgentSession {
return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
}
async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel: ThinkingLevel }>> {
async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel?: ThinkingLevel }>> {
const apiKeysByProvider = new Map<string, string | undefined>();
const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];
const result: Array<{ model: Model; thinkingLevel?: ThinkingLevel }> = [];
for (const scoped of this.#scopedModels) {
const provider = scoped.model.provider;
@@ -2798,7 +2803,7 @@ export class AgentSession {
this.settings.setModelRole("default", this.#formatRoleModelValue("default", next.model));
this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
// Apply thinking level (setThinkingLevel clamps to model capabilities)
// Apply the scoped model's configured thinking level
this.setThinkingLevel(next.thinkingLevel);
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
@@ -2826,7 +2831,7 @@ export class AgentSession {
this.settings.setModelRole("default", this.#formatRoleModelValue("default", nextModel));
this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
// Re-clamp thinking level for new model's capabilities without persisting settings
// Re-apply the current thinking level for the newly selected model
this.setThinkingLevel(this.thinkingLevel);
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
@@ -2845,21 +2850,18 @@ export class AgentSession {
/**
* Set thinking level.
* Clamps to model capabilities based on available thinking levels.
* Saves to session and settings only if the level actually changes.
* Saves the effective metadata-clamped level to session and settings only if it changes.
*/
setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void {
const availableLevels = this.getAvailableThinkingLevels();
const effectiveLevel = availableLevels.includes(level) ? level : this.#clampThinkingLevel(level, availableLevels);
setThinkingLevel(level: ThinkingLevel | undefined, persist: boolean = false): void {
const effectiveLevel = resolveThinkingLevelForModel(this.model, level);
const isChanging = effectiveLevel !== this.#thinkingLevel;
// Only persist if actually changing
const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
this.agent.setThinkingLevel(effectiveLevel);
this.#thinkingLevel = effectiveLevel;
this.agent.setThinkingLevel(toReasoningEffort(effectiveLevel));
if (isChanging) {
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
if (persist) {
if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
this.settings.set("defaultThinkingLevel", effectiveLevel);
}
}
@@ -2869,13 +2871,17 @@ export class AgentSession {
* Cycle to next thinking level.
* @returns New level, or undefined if model doesn't support thinking
*/
cycleThinkingLevel(): ThinkingLevel | undefined {
if (!this.supportsThinking()) return undefined;
cycleThinkingLevel(): Effort | undefined {
if (!this.model?.reasoning) return undefined;
const levels = this.getAvailableThinkingLevels();
const currentIndex = levels.indexOf(this.thinkingLevel);
const currentIndex =
this.thinkingLevel && this.thinkingLevel !== ThinkingLevel.Off && this.thinkingLevel !== ThinkingLevel.Inherit
? levels.indexOf(this.thinkingLevel)
: -1;
const nextIndex = (currentIndex + 1) % levels.length;
const nextLevel = levels[nextIndex];
if (!nextLevel) return undefined;
this.setThinkingLevel(nextLevel);
return nextLevel;
@@ -2903,43 +2909,10 @@ export class AgentSession {
/**
* Get available thinking levels for current model.
* The provider will clamp to what the specific model supports internally.
*/
getAvailableThinkingLevels(): ReadonlyArray<ThinkingLevel> {
if (!this.supportsThinking()) return ["off"];
return getAvailableThinkingLevels(this.supportsXhighThinking());
}
/**
* Check if current model supports xhigh thinking level.
*/
supportsXhighThinking(): boolean {
return this.model ? supportsXhigh(this.model) : false;
}
/**
* Check if current model supports thinking/reasoning.
*/
supportsThinking(): boolean {
return !!this.model?.reasoning;
}
#clampThinkingLevel(level: ThinkingLevel, availableLevels: ReadonlyArray<ThinkingLevel>): ThinkingLevel {
const ordered = getAvailableThinkingLevels(true);
const available = new Set(availableLevels);
const requestedIndex = ordered.indexOf(level);
if (requestedIndex === -1) {
return availableLevels[0] ?? "off";
}
for (let i = requestedIndex; i < ordered.length; i++) {
const candidate = ordered[i];
if (available.has(candidate)) return candidate;
}
for (let i = requestedIndex - 1; i >= 0; i--) {
const candidate = ordered[i];
if (available.has(candidate)) return candidate;
}
return availableLevels[0] ?? "off";
getAvailableThinkingLevels(): ReadonlyArray<Effort> {
if (!this.model) return [];
return getSupportedEfforts(this.model);
}
// =========================================================================
@@ -4647,18 +4620,15 @@ Be thorough - include exact file paths, function names, error messages, and tech
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
const hasServiceTierEntry = this.sessionManager.getBranch().some(entry => entry.type === "service_tier_change");
const defaultThinkingLevel = (this.settings.get("defaultThinkingLevel") ?? "off") as ThinkingLevel;
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
if (hasThinkingEntry) {
// Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel | undefined);
} else {
const availableLevels = this.getAvailableThinkingLevels();
const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: this.#clampThinkingLevel(defaultThinkingLevel, availableLevels);
this.agent.setThinkingLevel(effectiveLevel);
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
const effectiveDefaultThinkingLevel = resolveThinkingLevelForModel(this.model, defaultThinkingLevel);
this.#thinkingLevel = effectiveDefaultThinkingLevel;
this.agent.setThinkingLevel(toReasoningEffort(effectiveDefaultThinkingLevel));
this.sessionManager.appendThinkingLevelChange(effectiveDefaultThinkingLevel);
}
if (hasServiceTierEntry) {
@@ -5181,7 +5151,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
// Include model and thinking level
const model = this.agent.state.model;
const thinkingLevel = this.agent.state.thinkingLevel;
const thinkingLevel = this.#thinkingLevel;
lines.push("## Configuration\n");
lines.push(`Model: ${model.provider}/${model.id}`);
lines.push(`Thinking Level: ${thinkingLevel}`);
@@ -5,8 +5,7 @@
* and after compaction the session is reloaded.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { type AssistantMessage, completeSimple, Effort, type Model, type Usage } from "@oh-my-pi/pi-ai";
import {
CODEX_BASE_URL,
getCodexAccountId,
@@ -985,7 +984,7 @@ export async function generateSummary(
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ maxTokens, signal, apiKey, reasoning: "high" },
{ maxTokens, signal, apiKey, reasoning: Effort.High },
);
if (response.stopReason === "error") {
@@ -1034,7 +1033,7 @@ async function generateShortSummary(
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
},
{ maxTokens, signal, apiKey, reasoning: "high" },
{ maxTokens, signal, apiKey, reasoning: Effort.High },
);
if (response.stopReason === "error") {
@@ -1335,7 +1334,7 @@ async function generateTurnPrefixSummary(
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ maxTokens, signal, apiKey, reasoning: "high" },
{ maxTokens, signal, apiKey, reasoning: Effort.High },
);
if (response.stopReason === "error") {
@@ -67,7 +67,7 @@ export interface SessionMessageEntry extends SessionEntryBase {
export interface ThinkingLevelChangeEntry extends SessionEntryBase {
type: "thinking_level_change";
thinkingLevel: string;
thinkingLevel?: string | null;
}
export interface ModelChangeEntry extends SessionEntryBase {
@@ -209,7 +209,7 @@ export interface SessionTreeNode {
export interface SessionContext {
messages: AgentMessage[];
thinkingLevel: string;
thinkingLevel?: string;
serviceTier?: ServiceTier;
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
models: Record<string, string>;
@@ -433,7 +433,7 @@ export function buildSessionContext(
// Explicitly null - return no messages (navigated to before first entry)
return {
messages: [],
thinkingLevel: "off",
thinkingLevel: undefined,
serviceTier: undefined,
models: {},
injectedTtsrRules: [],
@@ -451,7 +451,7 @@ export function buildSessionContext(
if (!leaf) {
return {
messages: [],
thinkingLevel: "off",
thinkingLevel: undefined,
serviceTier: undefined,
models: {},
injectedTtsrRules: [],
@@ -468,7 +468,7 @@ export function buildSessionContext(
}
// Extract settings and find compaction
let thinkingLevel = "off";
let thinkingLevel: string | undefined;
let serviceTier: ServiceTier | undefined;
const models: Record<string, string> = {};
let compaction: CompactionEntry | null = null;
@@ -478,7 +478,7 @@ export function buildSessionContext(
for (const entry of path) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel;
thinkingLevel = entry.thinkingLevel ?? undefined;
} else if (entry.type === "model_change") {
// New format: { model: "provider/id", role?: string }
if (entry.model) {
@@ -1829,13 +1829,13 @@ export class SessionManager {
}
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
appendThinkingLevelChange(thinkingLevel: string): string {
appendThinkingLevelChange(thinkingLevel?: string): string {
const entry: ThinkingLevelChangeEntry = {
type: "thinking_level_change",
id: generateId(this.#byId),
parentId: this.#leafId,
timestamp: new Date().toISOString(),
thinkingLevel,
thinkingLevel: thinkingLevel ?? null,
};
this.#appendEntry(entry);
return entry.id;
+3 -2
View File
@@ -3,6 +3,7 @@
*
* Agents are embedded at build time via Bun's import with { type: "text" }.
*/
import { Effort } from "@oh-my-pi/pi-ai";
import { renderPromptTemplate } from "../config/prompt-templates";
import { parseAgentFields } from "../discovery/helpers";
import designerMd from "../prompts/agents/designer.md" with { type: "text" };
@@ -53,7 +54,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
description: "General-purpose subagent with full capabilities for delegated multi-step tasks",
spawns: "*",
model: "default",
thinkingLevel: "medium",
thinkingLevel: Effort.Medium,
},
template: taskMd,
},
@@ -63,7 +64,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
name: "quick_task",
description: "Low-reasoning agent for strictly mechanical updates or data collection only",
model: "pi/smol",
thinkingLevel: "minimal",
thinkingLevel: Effort.Minimal,
},
template: taskMd,
},
+2 -2
View File
@@ -4,8 +4,8 @@
* Runs each subagent on the main thread and forwards AgentEvents for progress tracking.
*/
import path from "node:path";
import type { AgentEvent } from "@oh-my-pi/pi-agent-core";
import type { Api, Model, ThinkingLevel, ToolChoice } from "@oh-my-pi/pi-ai";
import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
import { logger, untilAborted } from "@oh-my-pi/pi-utils";
import type { TSchema } from "@sinclair/typebox";
import Ajv, { type ValidateFunction } from "ajv";
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ThinkingLevel, Usage } from "@oh-my-pi/pi-ai";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
import { $env } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { NestedRepoPatch } from "./worktree";
+87
View File
@@ -0,0 +1,87 @@
import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { clampThinkingLevelForModel, type Effort, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
/**
* Metadata used to render thinking selector values in the coding-agent UI.
*/
export interface ThinkingLevelMetadata {
value: ThinkingLevel;
label: string;
description: string;
}
const THINKING_LEVEL_METADATA: Record<ThinkingLevel, ThinkingLevelMetadata> = {
[ThinkingLevel.Inherit]: {
value: ThinkingLevel.Inherit,
label: "inherit",
description: "Inherit session default",
},
[ThinkingLevel.Off]: { value: ThinkingLevel.Off, label: "off", description: "No reasoning" },
[ThinkingLevel.Minimal]: {
value: ThinkingLevel.Minimal,
label: "min",
description: "Very brief reasoning (~1k tokens)",
},
[ThinkingLevel.Low]: { value: ThinkingLevel.Low, label: "low", description: "Light reasoning (~2k tokens)" },
[ThinkingLevel.Medium]: {
value: ThinkingLevel.Medium,
label: "medium",
description: "Moderate reasoning (~8k tokens)",
},
[ThinkingLevel.High]: { value: ThinkingLevel.High, label: "high", description: "Deep reasoning (~16k tokens)" },
[ThinkingLevel.XHigh]: {
value: ThinkingLevel.XHigh,
label: "xhigh",
description: "Maximum reasoning (~32k tokens)",
},
};
const THINKING_LEVELS = new Set<string>([ThinkingLevel.Inherit, ThinkingLevel.Off, ...THINKING_EFFORTS]);
const EFFORT_LEVELS = new Set<string>(THINKING_EFFORTS);
/**
* Parses a provider-facing effort value.
*/
export function parseEffort(value: string | null | undefined): Effort | undefined {
return value !== undefined && value !== null && EFFORT_LEVELS.has(value) ? (value as Effort) : undefined;
}
/**
* Parses an agent-local thinking selector.
*/
export function parseThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined {
return value !== undefined && value !== null && THINKING_LEVELS.has(value) ? (value as ThinkingLevel) : undefined;
}
/**
* Returns display metadata for a thinking selector.
*/
export function getThinkingLevelMetadata(level: ThinkingLevel): ThinkingLevelMetadata {
return THINKING_LEVEL_METADATA[level];
}
/**
* Converts an agent-local selector into the effort sent to providers.
*/
export function toReasoningEffort(level: ThinkingLevel | undefined): Effort | undefined {
if (level === undefined || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) {
return undefined;
}
return level;
}
/**
* Resolves a selector against the current model while preserving explicit "off".
*/
export function resolveThinkingLevelForModel(
model: Model | undefined,
level: ThinkingLevel | undefined,
): ResolvedThinkingLevel | undefined {
if (level === undefined || level === ThinkingLevel.Inherit) {
return undefined;
}
if (level === ThinkingLevel.Off) {
return ThinkingLevel.Off;
}
return clampThinkingLevelForModel(model, level);
}
@@ -1,13 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import {
getBundledModel,
getBundledModels,
getBundledProviders,
supportsXhigh,
type ThinkingLevel,
} from "@oh-my-pi/pi-ai";
import { Effort, getBundledModel } from "@oh-my-pi/pi-ai";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
@@ -37,18 +31,9 @@ describe("AgentSession role model thinking behavior", () => {
return model;
}
function getReasoningModelWithoutXhighOrThrow() {
for (const provider of getBundledProviders()) {
for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0])) {
if (model.reasoning && !supportsXhigh(model)) return model;
}
}
throw new Error("Expected at least one bundled reasoning model without xhigh support");
}
async function createSession(options: {
initialModelId: string;
initialThinkingLevel: ThinkingLevel;
initialThinkingLevel: Effort;
modelRoles: Record<string, string>;
}) {
const model = getAnthropicModelOrThrow(options.initialModelId);
@@ -83,7 +68,7 @@ describe("AgentSession role model thinking behavior", () => {
await createSession({
initialModelId: defaultModel.id,
initialThinkingLevel: "high",
initialThinkingLevel: Effort.High,
modelRoles: {
default: `${defaultModel.provider}/${defaultModel.id}`,
slow: `${slowModel.provider}/${slowModel.id}:off`,
@@ -96,13 +81,13 @@ describe("AgentSession role model thinking behavior", () => {
expect(firstSwitch?.thinkingLevel).toBe("off");
expect(session.thinkingLevel).toBe("off");
session.setThinkingLevel("high");
expect(session.thinkingLevel).toBe("high");
session.setThinkingLevel(Effort.High);
expect(session.thinkingLevel).toBe(Effort.High);
const secondSwitch = await session.cycleRoleModels(["default", "slow"]);
expect(secondSwitch?.role).toBe("default");
expect(secondSwitch?.model.id).toBe(defaultModel.id);
expect(session.thinkingLevel).toBe("high");
expect(session.thinkingLevel).toBe(Effort.High);
const thirdSwitch = await session.cycleRoleModels(["default", "slow"]);
expect(thirdSwitch?.role).toBe("slow");
@@ -117,7 +102,7 @@ describe("AgentSession role model thinking behavior", () => {
await createSession({
initialModelId: defaultModel.id,
initialThinkingLevel: "low",
initialThinkingLevel: Effort.Low,
modelRoles: {
default: `${defaultModel.provider}/${defaultModel.id}`,
slow: `${slowModel.provider}/${slowModel.id}:high`,
@@ -126,17 +111,17 @@ describe("AgentSession role model thinking behavior", () => {
const toSlow = await session.cycleRoleModels(["default", "slow"]);
expect(toSlow?.role).toBe("slow");
expect(toSlow?.thinkingLevel).toBe("high");
expect(session.thinkingLevel).toBe("high");
expect(toSlow?.thinkingLevel).toBe(Effort.High);
expect(session.thinkingLevel).toBe(Effort.High);
session.setThinkingLevel("minimal");
expect(session.thinkingLevel).toBe("minimal");
session.setThinkingLevel(Effort.Minimal);
expect(session.thinkingLevel).toBe(Effort.Minimal);
const toDefault = await session.cycleRoleModels(["default", "slow"]);
expect(toDefault?.role).toBe("default");
expect(toDefault?.model.id).toBe(defaultModel.id);
expect(toDefault?.thinkingLevel).toBe("minimal");
expect(session.thinkingLevel).toBe("minimal");
expect(toDefault?.thinkingLevel).toBe(Effort.Minimal);
expect(session.thinkingLevel).toBe(Effort.Minimal);
});
it("applies slow role thinking even when plan shares the same model", async () => {
@@ -146,7 +131,7 @@ describe("AgentSession role model thinking behavior", () => {
await createSession({
initialModelId: defaultModel.id,
initialThinkingLevel: "medium",
initialThinkingLevel: Effort.Medium,
modelRoles: {
default: `${defaultModel.provider}/${defaultModel.id}`,
smol: `${smolModel.provider}/${smolModel.id}:low`,
@@ -157,14 +142,14 @@ describe("AgentSession role model thinking behavior", () => {
const toSmol = await session.cycleRoleModels(["slow", "default", "smol"]);
expect(toSmol?.role).toBe("smol");
expect(toSmol?.thinkingLevel).toBe("low");
expect(session.thinkingLevel).toBe("low");
expect(toSmol?.thinkingLevel).toBe(Effort.Low);
expect(session.thinkingLevel).toBe(Effort.Low);
const toSlow = await session.cycleRoleModels(["slow", "default", "smol"]);
expect(toSlow?.role).toBe("slow");
expect(toSlow?.model.id).toBe(slowPlanModel.id);
expect(toSlow?.thinkingLevel).toBe("high");
expect(session.thinkingLevel).toBe("high");
expect(toSlow?.thinkingLevel).toBe(Effort.High);
expect(session.thinkingLevel).toBe(Effort.High);
});
it("preserves explicit role thinking when updating default model despite unresolved previous model", async () => {
@@ -173,7 +158,7 @@ describe("AgentSession role model thinking behavior", () => {
await createSession({
initialModelId: defaultModel.id,
initialThinkingLevel: "high",
initialThinkingLevel: Effort.High,
modelRoles: {
default: "anthropic/nonexistent-model:off",
},
@@ -184,15 +169,15 @@ describe("AgentSession role model thinking behavior", () => {
expect(sessionSettings.getModelRole("default")).toBe(`${slowModel.provider}/${slowModel.id}:off`);
});
it("clamps unsupported xhigh to highest supported level instead of off", async () => {
const model = getReasoningModelWithoutXhighOrThrow();
it("clamps unsupported selections from model metadata", async () => {
const model = getAnthropicModelOrThrow("claude-sonnet-4-6");
const agent = new Agent({
initialState: {
model,
systemPrompt: "Test",
tools: [],
messages: [],
thinkingLevel: "off",
thinkingLevel: undefined,
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-non-xhigh.db"));
@@ -207,7 +192,8 @@ describe("AgentSession role model thinking behavior", () => {
modelRegistry,
});
session.setThinkingLevel("xhigh");
expect(session.thinkingLevel).toBe("high");
session.setThinkingLevel(Effort.XHigh);
expect(session.thinkingLevel).toBe(Effort.High);
expect(session.getAvailableThinkingLevels()).not.toContain("xhigh");
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { Effort } from "@oh-my-pi/pi-ai";
import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args";
describe("parseArgs", () => {
@@ -133,7 +134,7 @@ describe("parseArgs", () => {
test("parses --thinking", () => {
const result = parseArgs(["--thinking", "high"]);
expect(result.thinking).toBe("high");
expect(result.thinking).toBe(Effort.High);
});
test("parses --models as comma-separated list", () => {
@@ -247,7 +248,7 @@ describe("parseArgs", () => {
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-sonnet");
expect(result.print).toBe(true);
expect(result.thinking).toBe("high");
expect(result.thinking).toBe(Effort.High);
expect(result.fileArgs).toEqual(["prompt.md"]);
expect(result.messages).toEqual(["Do the task"]);
});
@@ -13,7 +13,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { getBundledModel, type Model, type ThinkingLevel } from "@oh-my-pi/pi-ai";
import { Effort, getBundledModel, type Model, type Effort as ThinkingLevelType } from "@oh-my-pi/pi-ai";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
@@ -47,7 +47,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
async function createSession(
modelId: "claude-opus-4-5-thinking" | "claude-sonnet-4-5",
thinkingLevel: ThinkingLevel = "high",
thinkingLevel: ThinkingLevelType = Effort.High,
) {
const toolSession: ToolSession = {
cwd: tempDir,
@@ -92,7 +92,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
}
it("should compact successfully with claude-opus-4-5-thinking and thinking level high", async () => {
createSession("claude-opus-4-5-thinking", "high");
createSession("claude-opus-4-5-thinking", Effort.High);
// Send a simple prompt
await session.prompt("Write down the first 10 prime numbers.");
@@ -119,7 +119,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
}, 180000);
it("should compact successfully with claude-sonnet-4-5 (non-thinking) for comparison", async () => {
createSession("claude-sonnet-4-5", "off");
createSession("claude-sonnet-4-5");
await session.prompt("Write down the first 10 prime numbers.");
await session.agent.waitForIdle();
@@ -156,7 +156,7 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic
}
});
async function createSession(model: Model, thinkingLevel: ThinkingLevel = "high") {
async function createSession(model: Model, thinkingLevel: ThinkingLevelType = Effort.High) {
const toolSession: ToolSession = {
cwd: tempDir,
hasUI: false,
@@ -196,7 +196,7 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic
it("should compact successfully with claude-3-7-sonnet and thinking level high", async () => {
const model = getBundledModel("anthropic", "claude-3-7-sonnet-latest")!;
createSession(model, "high");
createSession(model, Effort.High);
// Send a simple prompt
await session.prompt("Write down the first 10 prime numbers.");
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { Effort } from "@oh-my-pi/pi-ai";
import { parseAgentFields } from "../../src/discovery/helpers";
describe("parseAgentFields", () => {
@@ -42,7 +43,7 @@ describe("parseAgentFields", () => {
});
expect(fields).toBeDefined();
expect(fields?.thinkingLevel).toBe("medium");
expect(fields?.thinkingLevel).toBe(Effort.Medium);
});
test("prefers thinking-level over legacy thinking", () => {
@@ -50,9 +51,9 @@ describe("parseAgentFields", () => {
name: "reviewer",
description: "desc",
thinking: "minimal",
thinkingLevel: "high",
thinkingLevel: Effort.High,
});
expect(fields?.thinkingLevel).toBe("high");
expect(fields?.thinkingLevel).toBe(Effort.High);
});
});
@@ -5,6 +5,7 @@ import * as path from "node:path";
import {
type AssistantMessageEventStream,
clearCustomApis,
Effort,
getCustomApi,
getOAuthProviders,
type OAuthCredentials,
@@ -102,6 +103,36 @@ describe("ModelRegistry runtime provider registration", () => {
expect(model?.headers?.["X-Model"]).toBe("model-header");
});
test("registerProvider preserves explicit thinking on runtime models", () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const config: ProviderConfigInput = {
baseUrl: "https://runtime.example.com/v1",
apiKey: "RUNTIME_KEY",
api: "anthropic-messages",
models: [
{
...baseModel,
id: "runtime-thinking-model",
reasoning: true,
thinking: {
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
},
},
],
};
registry.registerProvider("runtime-provider", config, "ext://runtime");
const model = registry.find("runtime-provider", "runtime-thinking-model");
expect(model?.thinking).toEqual({
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
});
});
test("clearSourceRegistrations and syncExtensionSources remove source-scoped API and OAuth providers", () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const oauthCredentials: OAuthCredentials = {
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { OpenAICompat } from "@oh-my-pi/pi-ai";
import { Effort, type OpenAICompat, type ThinkingConfig } from "@oh-my-pi/pi-ai";
import { kNoAuth, MODEL_ROLES, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { Snowflake } from "@oh-my-pi/pi-utils";
@@ -38,6 +38,7 @@ describe("ModelRegistry", () => {
id: string;
name: string;
reasoning: boolean;
thinking?: ThinkingConfig;
input: string[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
@@ -48,7 +49,7 @@ describe("ModelRegistry", () => {
/** Create minimal provider config */
function providerConfig(
baseUrl: string,
models: Array<{ id: string; name?: string }>,
models: Array<{ id: string; name?: string; reasoning?: boolean; thinking?: ThinkingConfig }>,
api: string = "anthropic-messages",
) {
return {
@@ -58,7 +59,8 @@ describe("ModelRegistry", () => {
models: models.map(m => ({
id: m.id,
name: m.name ?? m.id,
reasoning: false,
reasoning: m.reasoning ?? false,
thinking: m.thinking,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100000,
@@ -350,6 +352,48 @@ describe("ModelRegistry", () => {
});
});
describe("thinking metadata normalization", () => {
test("custom models preserve explicit thinking", () => {
const thinking: ThinkingConfig = {
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
};
writeModelsJson({
anthropic: providerConfig("https://my-proxy.example.com/v1", [
{ id: "claude-custom", reasoning: true, thinking },
]),
});
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const model = getModelsForProvider(registry, "anthropic").find(m => m.id === "claude-custom");
expect(model?.thinking).toEqual(thinking);
});
test("model overrides can replace canonical thinking metadata", () => {
writeRawModelsJson({
openrouter: {
modelOverrides: {
"anthropic/claude-sonnet-4": {
thinking: { mode: "budget", minLevel: Effort.Low, maxLevel: Effort.Medium },
},
},
},
});
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const model = getModelsForProvider(registry, "openrouter").find(m => m.id === "anthropic/claude-sonnet-4");
expect(model?.thinking).toEqual({
mode: "budget",
minLevel: Effort.Low,
maxLevel: Effort.Medium,
});
});
});
describe("modelOverrides (per-model customization)", () => {
test("model override applies to a single built-in model", () => {
writeRawModelsJson({
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { Model } from "@oh-my-pi/pi-ai";
import { Effort, type Model } from "@oh-my-pi/pi-ai";
import {
parseModelPattern,
parseModelString,
@@ -18,6 +18,11 @@ const mockModels: Model<"anthropic-messages">[] = [
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: true,
thinking: {
mode: "budget",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
},
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
@@ -46,6 +51,11 @@ const mockOpenRouterModels: Model<"anthropic-messages">[] = [
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
thinking: {
mode: "budget",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
},
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
@@ -100,6 +110,11 @@ const mockCodexOverlapModels: Model<"anthropic-messages">[] = [
provider: "openai-codex",
baseUrl: "https://api.openai.com",
reasoning: true,
thinking: {
mode: "effort",
minLevel: Effort.Low,
maxLevel: Effort.XHigh,
},
input: ["text"],
cost: { input: 1.5, output: 6, cacheRead: 0.15, cacheWrite: 1.5 },
contextWindow: 200000,
@@ -112,6 +127,11 @@ const mockCodexOverlapModels: Model<"anthropic-messages">[] = [
provider: "openai-codex",
baseUrl: "https://api.openai.com",
reasoning: true,
thinking: {
mode: "effort",
minLevel: Effort.Low,
maxLevel: Effort.XHigh,
},
input: ["text"],
cost: { input: 1, output: 4, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 200000,
@@ -152,19 +172,19 @@ describe("parseModelPattern", () => {
test("sonnet:high returns sonnet with high thinking level", () => {
const result = parseModelPattern("sonnet:high", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("high");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.warning).toBeUndefined();
});
test("gpt-4o:medium returns gpt-4o with medium thinking level", () => {
const result = parseModelPattern("gpt-4o:medium", allModels);
expect(result.model?.id).toBe("gpt-4o");
expect(result.thinkingLevel).toBe("medium");
expect(result.thinkingLevel).toBe(Effort.Medium);
expect(result.warning).toBeUndefined();
});
test("all valid thinking levels work", () => {
const levels = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
const levels = ["off", Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] as const;
for (const level of levels) {
const result = parseModelPattern(`sonnet:${level}`, allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
@@ -214,7 +234,7 @@ describe("parseModelPattern", () => {
test("qwen3-coder:exacto:high matches model with high thinking level", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto:high", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe("high");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
expect(result.warning).toBeUndefined();
});
@@ -223,7 +243,7 @@ describe("parseModelPattern", () => {
const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto:high", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.model?.provider).toBe("openrouter");
expect(result.thinkingLevel).toBe("high");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
expect(result.warning).toBeUndefined();
});
@@ -308,7 +328,7 @@ describe("resolveModelRoleValue", () => {
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe("high");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
});
@@ -330,15 +350,24 @@ describe("resolveModelRoleValue", () => {
const providerQualified = resolveModelRoleValue("openai-codex/gpt-5.3-codex:xhigh", allModels);
expect(providerQualified.model?.provider).toBe("openai-codex");
expect(providerQualified.model?.id).toBe("gpt-5.3-codex");
expect(providerQualified.thinkingLevel).toBe("xhigh");
expect(providerQualified.thinkingLevel).toBe(Effort.XHigh);
expect(providerQualified.explicitThinkingLevel).toBe(true);
const idOnly = resolveModelRoleValue("gpt-5.3-codex:xhigh", allModels);
expect(idOnly.model?.provider).toBe("openai-codex");
expect(idOnly.model?.id).toBe("gpt-5.3-codex");
expect(idOnly.thinkingLevel).toBe("xhigh");
expect(idOnly.thinkingLevel).toBe(Effort.XHigh);
expect(idOnly.explicitThinkingLevel).toBe(true);
});
test("clamps explicit thinking selectors from model metadata", () => {
const result = resolveModelRoleValue("anthropic/claude-sonnet-4-5:xhigh", allModels);
expect(result.model?.provider).toBe("anthropic");
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
});
});
describe("resolveModelFromString", () => {
test("falls back to pattern parsing for provider/model:thinking when strict provider+id miss", () => {
@@ -376,7 +405,7 @@ describe("resolveModelOverride", () => {
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe("high");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
});
});
@@ -424,7 +453,7 @@ describe("resolveCliModel", () => {
expect(result.error).toBeUndefined();
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("high");
expect(result.thinkingLevel).toBe(Effort.High);
});
test("prefers exact model id match over provider inference (OpenRouter-style ids)", () => {
@@ -507,11 +536,11 @@ describe("parseModelString", () => {
describe("thinking level suffix extraction", () => {
test("extracts valid thinking level from provider/id:level", () => {
const result = parseModelString("anthropic/claude-sonnet-4-5:high");
expect(result).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5", thinkingLevel: "high" });
expect(result).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5", thinkingLevel: Effort.High });
});
test("extracts all valid thinking levels", () => {
const levels = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
const levels = ["off", Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] as const;
for (const level of levels) {
const result = parseModelString(`anthropic/claude-sonnet-4-5:${level}`);
expect(result?.id).toBe("claude-sonnet-4-5");
@@ -527,7 +556,11 @@ describe("parseModelString", () => {
test("handles model ID with colon followed by valid thinking level", () => {
// e.g. "openrouter/qwen/qwen3-coder:exacto:high" — last colon is thinking level
const result = parseModelString("openrouter/qwen/qwen3-coder:exacto:high");
expect(result).toEqual({ provider: "openrouter", id: "qwen/qwen3-coder:exacto", thinkingLevel: "high" });
expect(result).toEqual({
provider: "openrouter",
id: "qwen/qwen3-coder:exacto",
thinkingLevel: Effort.High,
});
});
test("does not extract thinking level from model ID with invalid suffix", () => {
+3 -3
View File
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentEvent, AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, TextContent } from "@oh-my-pi/pi-ai";
import { type AssistantMessage, Effort, type TextContent } from "@oh-my-pi/pi-ai";
import {
type CompactionEntry,
type FileEntry,
@@ -198,11 +198,11 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("RPC mode", () => {
await client.start();
// Set thinking level
await client.setThinkingLevel("high");
await client.setThinkingLevel(Effort.High);
// Verify via state
const state = await client.getState();
expect(state.thinkingLevel).toBe("high");
expect(state.thinkingLevel).toBe(Effort.High);
}, 30000);
test("should cycle thinking level", async () => {
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Effort } from "@oh-my-pi/pi-ai";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getPathsForTab, getUi } from "@oh-my-pi/pi-coding-agent/config/settings-schema";
import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils";
@@ -70,12 +71,12 @@ describe("Settings", () => {
});
// Settings saves a change - should merge, not overwrite
settings.set("defaultThinkingLevel", "high");
settings.set("defaultThinkingLevel", Effort.High);
await settings.flush();
const savedSettings = await readSettings();
expect(savedSettings.enabledModels).toEqual(["claude-opus-4-5", "gpt-5.2-codex"]);
expect(savedSettings.defaultThinkingLevel).toBe("high");
expect(savedSettings.defaultThinkingLevel).toBe(Effort.High);
expect(savedSettings.theme).toEqual({ dark: "anthracite" });
expect((savedSettings.modelRoles as { default?: string } | undefined)?.default).toBe("claude-sonnet");
});
@@ -111,14 +112,14 @@ describe("Settings", () => {
await writeSettings({
theme: { dark: "anthracite" },
defaultThinkingLevel: "low",
defaultThinkingLevel: Effort.Low,
});
settings.set("defaultThinkingLevel", "high");
settings.set("defaultThinkingLevel", Effort.High);
await settings.flush();
const savedSettings = await readSettings();
expect(savedSettings.defaultThinkingLevel).toBe("high");
expect(savedSettings.defaultThinkingLevel).toBe(Effort.High);
});
});
describe("compaction remote setting", () => {
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { type AssistantMessage, Effort } from "@oh-my-pi/pi-ai";
import { Settings } from "../../src/config/settings";
import type { LoadExtensionsResult } from "../../src/extensibility/extensions/types";
import * as sdkModule from "../../src/sdk";
@@ -241,7 +241,7 @@ describe("runSubprocess submit_result reminders", () => {
...baseOptions,
id: "subagent-thinking-fallback",
modelOverride: "openai/gpt-4o",
thinkingLevel: "high",
thinkingLevel: Effort.High,
modelRegistry,
});
@@ -260,7 +260,7 @@ describe("runSubprocess submit_result reminders", () => {
} as unknown as import("../../src/config/model-registry").ModelRegistry;
const cases = [
{ modelOverride: "openai/gpt-4o:low", expectedThinkingLevel: "low" },
{ modelOverride: "openai/gpt-4o:low", expectedThinkingLevel: Effort.Low },
{ modelOverride: "openai/gpt-4o:off", expectedThinkingLevel: "off" },
] as const;
@@ -290,7 +290,7 @@ describe("runSubprocess submit_result reminders", () => {
...baseOptions,
id: `subagent-thinking-override-${index}`,
modelOverride: testCase.modelOverride,
thinkingLevel: "high",
thinkingLevel: Effort.High,
modelRegistry,
});
}
+12 -3
View File
@@ -11,13 +11,22 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { parseArgs } from "node:util";
import { getAvailableThinkingLevels, parseThinkingLevel, type ThinkingLevel } from "@oh-my-pi/pi-ai";
import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { padding } from "@oh-my-pi/pi-tui";
import { TempDir } from "@oh-my-pi/pi-utils";
import { generateJsonReport, generateReport } from "./report";
import { type BenchmarkConfig, type ProgressEvent, runBenchmark } from "./runner";
import { type EditTask, loadTasksFromDir, validateFixturesFromDir } from "./tasks";
function parseThinkingLevel(value: string | null | undefined): ResolvedThinkingLevel | undefined {
return value !== undefined &&
value !== null &&
[ThinkingLevel.Off, ...THINKING_EFFORTS].includes(value as ResolvedThinkingLevel)
? (value as ResolvedThinkingLevel)
: undefined;
}
function generateReportFilename(config: BenchmarkConfig, format: "markdown" | "json"): string {
const modelName = config.model
.split("/")
@@ -207,12 +216,12 @@ async function main(): Promise<void> {
process.exit(0);
}
let thinkingLevel: ThinkingLevel = "low";
let thinkingLevel: ResolvedThinkingLevel = Effort.Low;
if (values.thinking) {
const level = parseThinkingLevel(values.thinking);
if (!level) {
console.error(`Invalid thinking level: ${values.thinking}`);
console.error(`Valid levels: ${getAvailableThinkingLevels().join(", ")}`);
console.error(`Valid levels: ${[ThinkingLevel.Off, ...THINKING_EFFORTS].join(", ")}`);
process.exit(1);
}
thinkingLevel = level;
+2 -2
View File
@@ -7,7 +7,7 @@
/// <reference types="./bun-imports.d.ts" />
import * as fs from "node:fs";
import * as path from "node:path";
import type { ThinkingLevel } from "@oh-my-pi/pi-ai";
import type { ResolvedThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { computeLineHash, RpcClient, renderPromptTemplate } from "@oh-my-pi/pi-coding-agent";
import { Snowflake } from "@oh-my-pi/pi-utils";
@@ -31,7 +31,7 @@ function makeTempDir(pre?: string): string {
export interface BenchmarkConfig {
provider: string;
model: string;
thinkingLevel?: ThinkingLevel;
thinkingLevel?: ResolvedThinkingLevel;
runsPerTask: number;
timeout: number;
maxTurns?: number;
View File
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env python3
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import asdict, dataclass
from pathlib import Path
import argparse
import json
import re
import sys
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parent))
from tool_io import ReservoirSample, ToolIOConfig, ToolInvocation, iter_tool_invocations, list_recent_session_files
else:
from scripts.tool_io import ReservoirSample, ToolIOConfig, ToolInvocation, iter_tool_invocations, list_recent_session_files
TOOL_NAMES = ("edit", "ast_edit")
@dataclass(slots=True)
class DiffSummary:
small: bool
added_lines: int
removed_lines: int
changed_lines: int
changed_preview: list[str]
category: str | None = None
@dataclass(slots=True)
class CompletedEdit:
session_file: str
tool_call_id: str
tool_name: str
path: str
args: dict[str, object]
result_text: str
diff: str | None
is_error: bool
assistant_thinking: str | None
assistant_timestamp: str | None
tool_timestamp: str | None
issue: str
small: bool
small_category: str | None
added_lines: int
removed_lines: int
changed_lines: int
changed_preview: list[str]
@dataclass(slots=True)
class PreviousEditSummary:
tool_name: str
path: str
issue: str
is_error: bool
small: bool
same_path: bool
changed_preview: list[str]
@dataclass(slots=True)
class Candidate:
kind: str
edit: CompletedEdit
previous_edit: PreviousEditSummary | None = None
@dataclass(slots=True)
class RunStats:
files_scanned: int = 0
total_edit_attempts: int = 0
failed_edits: int = 0
small_edits: int = 0
small_edits_with_previous_edit: int = 0
small_edits_with_previous_same_path: int = 0
small_edits_after_failed_edit: int = 0
small_edits_after_same_path_failed_edit: int = 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Analyze small edit/ast_edit tool usage in session logs.")
parser.add_argument("--sessions-dir", type=Path, default=Path.home() / ".omp" / "agent" / "sessions")
parser.add_argument("--sample-size", type=positive_int, default=30)
parser.add_argument("--max-files", type=positive_int, default=500)
parser.add_argument("--since-days", type=positive_int, default=30)
parser.add_argument("--max-items", type=positive_int, default=50_000)
parser.add_argument("--limit-mode", choices=("calls", "events"), default="calls")
parser.add_argument("--json", action="store_true")
return parser.parse_args()
def positive_int(value: str) -> int:
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("value must be a positive integer")
return parsed
def strip_decorations(line: str) -> str:
return re.sub(r"^\s*\d+\s+", "", line).strip()
def is_delimiter_line(line: str) -> bool:
return bool(re.match(r"^[\]}),;]+$", line))
def is_tiny_structural_line(line: str) -> bool:
if len(line) == 0:
return True
if is_delimiter_line(line):
return True
if re.match(r"^(pub\s+mod|pub\s+use|mod|use|import|export)\b", line):
return True
if re.match(r"^(return|break|continue);$", line):
return True
if re.match(r"^[A-Za-z0-9_.$]+\([^)]*\);$", line) and len(line) <= 60:
return True
return False
def classify_success_issue(summary: DiffSummary) -> str:
previews = summary.changed_preview
if previews and all(len(line) == 0 for line in previews):
return "blank-line-adjustment"
if previews and all(is_delimiter_line(line) for line in previews):
return "delimiter-adjustment"
if previews and all(re.match(r"^(pub\s+mod|pub\s+use|mod|use|import|export)\b", line) for line in previews):
return "import-or-module-tweak"
if summary.removed_lines == 1 and summary.added_lines == 0:
return "single-line-delete"
if summary.added_lines == 1 and summary.removed_lines == 0:
return "single-line-add"
if summary.added_lines == 1 and summary.removed_lines == 1:
return "single-line-replace"
return "small-structural-fix"
def classify_failure_issue(result_text: str) -> str:
if re.search(r"identical content|No changes made", result_text, re.IGNORECASE):
return "no-op-identical"
if re.search(r"Failed to find context|matches for context|expected lines|tag mismatch|>>>", result_text, re.IGNORECASE):
return "context-mismatch"
if re.search(r"Unexpected line in hunk|parse error|SyntaxError", result_text, re.IGNORECASE):
return "invalid-patch-shape"
if re.search(r"File not found", result_text, re.IGNORECASE):
return "missing-file"
if re.search(r"occurrence|ambiguous", result_text, re.IGNORECASE):
return "ambiguous-target"
if re.search(r"Validation failed|required property|must have required property", result_text, re.IGNORECASE):
return "invalid-arguments"
return "other-failure"
def summarize_diff(diff: str | None) -> DiffSummary:
if not diff:
return DiffSummary(
small=False,
added_lines=0,
removed_lines=0,
changed_lines=0,
changed_preview=[],
)
added: list[str] = []
removed: list[str] = []
for raw_line in diff.splitlines():
if raw_line.startswith(("+++", "---", "@@")):
continue
if raw_line.startswith("+"):
added.append(strip_decorations(raw_line[1:]))
continue
if raw_line.startswith("-"):
removed.append(strip_decorations(raw_line[1:]))
all_changes = [*removed, *added]
include_blank = previews_need_blank_marker(all_changes)
previews = [line for line in all_changes if line or include_blank]
changed_lines = len(added) + len(removed)
tiny_only = all(is_tiny_structural_line(line) for line in all_changes)
small = changed_lines > 0 and (changed_lines <= 2 or (changed_lines <= 4 and tiny_only))
preview_slice = previews[:4]
category = None
if small:
category = classify_success_issue(
DiffSummary(
small=small,
added_lines=len(added),
removed_lines=len(removed),
changed_lines=changed_lines,
changed_preview=preview_slice,
)
)
return DiffSummary(
small=small,
added_lines=len(added),
removed_lines=len(removed),
changed_lines=changed_lines,
changed_preview=preview_slice,
category=category,
)
def previews_need_blank_marker(lines: list[str]) -> bool:
return any(len(line) == 0 for line in lines)
def build_completed_edit(invocation: ToolInvocation) -> CompletedEdit | None:
if not invocation.has_result:
return None
diff_summary = summarize_diff(invocation.diff)
is_error = invocation.is_error
issue = classify_failure_issue(invocation.result_text) if is_error else (diff_summary.category or "other-success")
return CompletedEdit(
session_file=str(invocation.session_file),
tool_call_id=invocation.tool_call_id,
tool_name=invocation.tool_name,
path=invocation.path_hint,
args=invocation.arguments,
result_text=invocation.result_text,
diff=invocation.diff,
is_error=is_error,
assistant_thinking=invocation.assistant_thinking,
assistant_timestamp=invocation.assistant_timestamp,
tool_timestamp=invocation.tool_timestamp,
issue=issue,
small=(not is_error and diff_summary.small),
small_category=diff_summary.category,
added_lines=diff_summary.added_lines,
removed_lines=diff_summary.removed_lines,
changed_lines=diff_summary.changed_lines,
changed_preview=diff_summary.changed_preview,
)
def analyze_small_edits(stream: Iterable[ToolInvocation], *, sample_size: int, files_scanned: int) -> dict[str, object]:
sample: ReservoirSample[Candidate] = ReservoirSample(size=sample_size)
issue_counts: dict[str, int] = {}
stats = RunStats(files_scanned=files_scanned)
last_edit: CompletedEdit | None = None
for invocation in stream:
completed = build_completed_edit(invocation)
if completed is None:
continue
stats.total_edit_attempts += 1
if completed.is_error:
stats.failed_edits += 1
issue_counts[completed.issue] = issue_counts.get(completed.issue, 0) + 1
sample.add(Candidate(kind="failed", edit=completed))
if completed.small:
stats.small_edits += 1
issue_counts[completed.issue] = issue_counts.get(completed.issue, 0) + 1
previous = None
if last_edit is not None:
stats.small_edits_with_previous_edit += 1
if last_edit.is_error:
stats.small_edits_after_failed_edit += 1
if last_edit.path and last_edit.path == completed.path:
stats.small_edits_with_previous_same_path += 1
if last_edit.is_error:
stats.small_edits_after_same_path_failed_edit += 1
previous = PreviousEditSummary(
tool_name=last_edit.tool_name,
path=last_edit.path,
issue=last_edit.issue,
is_error=last_edit.is_error,
small=last_edit.small,
same_path=last_edit.path == completed.path,
changed_preview=last_edit.changed_preview,
)
sample.add(Candidate(kind="small", edit=completed, previous_edit=previous))
last_edit = completed
return {
"stats": asdict(stats),
"top_issues": top_entries(issue_counts, 20),
"sample": [candidate_to_dict(candidate) for candidate in sample.items],
}
def candidate_to_dict(candidate: Candidate) -> dict[str, object]:
payload = {"kind": candidate.kind, "edit": asdict(candidate.edit)}
if candidate.previous_edit is not None:
payload["previous_edit"] = asdict(candidate.previous_edit)
return payload
def top_entries(counts: dict[str, int], limit: int) -> list[dict[str, object]]:
return [
{"name": name, "count": count}
for name, count in sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))[:limit]
]
def short_path(target_path: str) -> str:
home = str(Path.home())
return f"~{target_path[len(home):]}" if target_path.startswith(home) else target_path
def truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return f"{text[: limit - 1]}…"
def format_sample_entry(candidate: dict[str, object], index: int) -> str:
edit = candidate["edit"]
assert isinstance(edit, dict)
lines = [
f"{index + 1}. [{candidate['kind']}] {edit['issue']}",
f" file: {Path(str(edit['session_file'])).name}",
f" target: {short_path(str(edit['path'])) if edit['path'] else '(unknown path)'}",
f" tool: {edit['tool_name']}",
]
if candidate["kind"] == "small":
lines.append(
f" change: +{edit['added_lines']} / -{edit['removed_lines']} ({edit['changed_lines']} changed line(s))"
)
changed_preview = edit.get("changed_preview")
if isinstance(changed_preview, list) and changed_preview:
lines.append(f" preview: {' | '.join(str(item) for item in changed_preview)}")
previous = candidate.get("previous_edit")
if isinstance(previous, dict):
path_part = f" ({short_path(str(previous['path']))})" if previous.get("path") else ""
lines.append(
" previous edit: "
f"{'same-path' if previous.get('same_path') else 'other-path'} "
f"{'failed' if previous.get('is_error') else previous.get('issue')}{path_part}"
)
previous_preview = previous.get("changed_preview")
if isinstance(previous_preview, list) and previous_preview:
lines.append(f" previous preview: {' | '.join(str(item) for item in previous_preview)}")
else:
lines.append(" previous edit: none")
else:
lines.append(f" result: {truncate(' '.join(str(edit['result_text']).split()), 220)}")
changed_preview = edit.get("changed_preview")
if isinstance(changed_preview, list) and changed_preview:
lines.append(f" diff preview: {' | '.join(str(item) for item in changed_preview)}")
return '\n'.join(lines)
def main() -> None:
options = parse_args()
config = ToolIOConfig(
sessions_dir=options.sessions_dir,
since_days=options.since_days,
max_files=options.max_files,
max_items=options.max_items,
limit_mode=options.limit_mode,
include_unresolved=False,
)
files = list_recent_session_files(config)
stream = iter_tool_invocations(TOOL_NAMES, config)
analysis = analyze_small_edits(stream, sample_size=options.sample_size, files_scanned=len(files))
if options.json:
print(
json.dumps(
{
"options": {
"sessions_dir": str(options.sessions_dir),
"sample_size": options.sample_size,
"max_files": options.max_files,
"since_days": options.since_days,
"max_items": options.max_items,
"limit_mode": options.limit_mode,
"json": options.json,
},
**analysis,
},
indent=2,
)
)
return
stats = analysis["stats"]
top_issues = analysis["top_issues"]
sample = analysis["sample"]
assert isinstance(stats, dict)
assert isinstance(top_issues, list)
assert isinstance(sample, list)
print(f"Scanned {stats['files_scanned']} session file(s) from {short_path(str(options.sessions_dir))}")
print(f"Edit attempts: {stats['total_edit_attempts']}")
print(f"Failed edits: {stats['failed_edits']}")
print(f"Small edits: {stats['small_edits']}")
print(f"Small edits with previous edit: {stats['small_edits_with_previous_edit']}")
print(f"Small edits with previous same-path edit: {stats['small_edits_with_previous_same_path']}")
print(f"Small edits after failed edit: {stats['small_edits_after_failed_edit']}")
print(f"Small edits after same-path failed edit: {stats['small_edits_after_same_path_failed_edit']}")
print()
print("Top issues:")
for entry in top_issues[:12]:
assert isinstance(entry, dict)
print(f" - {entry['name']}: {entry['count']}")
print()
print(f"Random sample ({len(sample)}):")
for index, candidate in enumerate(sample):
assert isinstance(candidate, dict)
print(format_sample_entry(candidate, index))
print()
if __name__ == "__main__":
main()
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
from __future__ import annotations
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
import json
import random
import time
from typing import Any, Literal
LimitMode = Literal["calls", "events"]
DEFAULT_MAX_ITEMS = 50_000
DEFAULT_SINCE_DAYS = 30
DEFAULT_MAX_FILES = 500
DEFAULT_SESSIONS_DIR = Path.home() / ".omp" / "agent" / "sessions"
TOOL_GROUPS: dict[str, tuple[str, ...]] = {
"edits": ("edit", "ast_edit"),
"reads": ("read", "grep", "find", "ast_grep", "lsp"),
"writes": ("edit", "ast_edit", "write"),
}
@dataclass(slots=True)
class ToolIOConfig:
sessions_dir: Path = DEFAULT_SESSIONS_DIR
since_days: int = DEFAULT_SINCE_DAYS
max_files: int = DEFAULT_MAX_FILES
max_items: int = DEFAULT_MAX_ITEMS
limit_mode: LimitMode = "calls"
include_unresolved: bool = True
@dataclass(slots=True)
class ToolCall:
session_file: Path
tool_call_id: str
tool_name: str
arguments: dict[str, Any]
assistant_thinking: str | None = None
assistant_timestamp: str | None = None
path_hint: str = ""
@dataclass(slots=True)
class ToolResult:
tool_call_id: str
tool_name: str
is_error: bool
result_text: str
details: dict[str, Any] = field(default_factory=dict)
tool_timestamp: str | None = None
@dataclass(slots=True)
class ToolInvocation:
call: ToolCall
result: ToolResult | None = None
@property
def session_file(self) -> Path:
return self.call.session_file
@property
def tool_call_id(self) -> str:
return self.call.tool_call_id
@property
def tool_name(self) -> str:
return self.call.tool_name
@property
def arguments(self) -> dict[str, Any]:
return self.call.arguments
@property
def assistant_thinking(self) -> str | None:
return self.call.assistant_thinking
@property
def assistant_timestamp(self) -> str | None:
return self.call.assistant_timestamp
@property
def tool_timestamp(self) -> str | None:
return self.result.tool_timestamp if self.result else None
@property
def path_hint(self) -> str:
return self.call.path_hint
@property
def has_result(self) -> bool:
return self.result is not None
@property
def is_error(self) -> bool:
return bool(self.result and self.result.is_error)
@property
def result_text(self) -> str:
return self.result.result_text if self.result else ""
@property
def details(self) -> dict[str, Any]:
return self.result.details if self.result else {}
@property
def diff(self) -> str | None:
diff = self.details.get("diff")
return diff if isinstance(diff, str) else None
@dataclass(slots=True)
class ReservoirSample[T]:
size: int
items: list[T] = field(default_factory=list)
seen: int = 0
rng: random.Random = field(default_factory=random.Random)
def add(self, item: T) -> None:
if self.size <= 0:
return
self.seen += 1
if len(self.items) < self.size:
self.items.append(item)
return
index = self.rng.randrange(self.seen)
if index < self.size:
self.items[index] = item
def list_recent_session_files(config: ToolIOConfig) -> list[Path]:
min_mtime = time.time() - config.since_days * 24 * 60 * 60
candidates: list[tuple[float, Path]] = []
for session_file in config.sessions_dir.rglob("*.jsonl"):
try:
stat = session_file.stat()
except FileNotFoundError:
continue
if stat.st_mtime < min_mtime:
continue
candidates.append((stat.st_mtime, session_file))
candidates.sort(key=lambda entry: entry[0], reverse=True)
return [entry[1] for entry in candidates[: config.max_files]]
def iter_tool_invocations(
tool_names: str | Iterable[str],
config: ToolIOConfig | None = None,
) -> Iterator[ToolInvocation]:
resolved = config or ToolIOConfig()
wanted = _normalize_tool_names(tool_names)
seen_items = 0
for session_file in list_recent_session_files(resolved):
pending: dict[str, ToolCall] = {}
for entry in _iter_session_entries(session_file):
if entry.get("type") != "message":
continue
message = _as_record(entry.get("message"))
if message is None:
continue
role = message.get("role")
if role == "assistant":
content = message.get("content")
if not isinstance(content, list):
continue
thinking = _extract_thinking(content)
assistant_timestamp = _as_string(entry.get("timestamp"))
for item in content:
payload = _as_record(item)
if payload is None:
continue
if payload.get("type") != "toolCall":
continue
tool_name = _as_string(payload.get("name"))
tool_call_id = _as_string(payload.get("id"))
if tool_name is None or tool_call_id is None or tool_name not in wanted:
continue
arguments = _as_record(payload.get("arguments")) or {}
pending[tool_call_id] = ToolCall(
session_file=session_file,
tool_call_id=tool_call_id,
tool_name=tool_name,
arguments=arguments,
assistant_thinking=thinking,
assistant_timestamp=assistant_timestamp,
path_hint=extract_path(arguments),
)
continue
if role != "toolResult":
continue
tool_name = _as_string(message.get("toolName"))
tool_call_id = _as_string(message.get("toolCallId"))
if tool_name is None or tool_call_id is None or tool_name not in wanted:
continue
pending_call = pending.pop(tool_call_id, None)
if pending_call is None:
continue
result = ToolResult(
tool_call_id=tool_call_id,
tool_name=tool_name,
is_error=message.get("isError") is True,
result_text=extract_result_text(message),
details=_as_record(message.get("details")) or {},
tool_timestamp=_as_string(entry.get("timestamp")),
)
invocation = ToolInvocation(call=pending_call, result=result)
seen_items += _event_weight(invocation, resolved.limit_mode)
yield invocation
if seen_items >= resolved.max_items:
return
if not resolved.include_unresolved:
continue
for pending_call in pending.values():
invocation = ToolInvocation(call=pending_call)
seen_items += _event_weight(invocation, resolved.limit_mode)
yield invocation
if seen_items >= resolved.max_items:
return
def iter_results(stream: Iterable[ToolInvocation]) -> Iterator[ToolInvocation]:
for invocation in stream:
if invocation.has_result:
yield invocation
def iter_failed(stream: Iterable[ToolInvocation]) -> Iterator[ToolInvocation]:
for invocation in stream:
if invocation.is_error:
yield invocation
def iter_successful(stream: Iterable[ToolInvocation]) -> Iterator[ToolInvocation]:
for invocation in stream:
if invocation.has_result and not invocation.is_error:
yield invocation
def iter_with_diff(stream: Iterable[ToolInvocation]) -> Iterator[ToolInvocation]:
for invocation in stream:
if invocation.diff:
yield invocation
def iter_paths(stream: Iterable[ToolInvocation], *paths: str) -> Iterator[ToolInvocation]:
wanted = set(paths)
for invocation in stream:
if invocation.path_hint in wanted:
yield invocation
def take(stream: Iterable[ToolInvocation], limit: int) -> Iterator[ToolInvocation]:
if limit <= 0:
return
remaining = limit
for invocation in stream:
if remaining <= 0:
return
yield invocation
remaining -= 1
def sample_reservoir[T](stream: Iterable[T], size: int, seed: int | None = None) -> list[T]:
sample: ReservoirSample[T] = ReservoirSample(size=size, rng=random.Random(seed))
for item in stream:
sample.add(item)
return sample.items
def extract_result_text(message: dict[str, Any] | None) -> str:
if message is None:
return ""
content = message.get("content")
if not isinstance(content, list):
return ""
for item in content:
payload = _as_record(item)
if payload is None:
continue
if payload.get("type") != "text":
continue
text = _as_string(payload.get("text"))
if text is not None:
return text
return ""
def extract_path(arguments: dict[str, Any]) -> str:
for key in ("path", "file", "move"):
value = arguments.get(key)
if isinstance(value, str):
return value
return ""
def _iter_session_entries(session_file: Path) -> Iterator[dict[str, Any]]:
with session_file.open("r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
payload = _as_record(entry)
if payload is not None:
yield payload
def _extract_thinking(content: list[Any]) -> str | None:
for item in content:
payload = _as_record(item)
if payload is None:
continue
if payload.get("type") != "thinking":
continue
thinking = _as_string(payload.get("thinking"))
if thinking:
return thinking
return None
def resolve_tool_names(*names_or_groups: str) -> tuple[str, ...]:
ordered: list[str] = []
seen: set[str] = set()
for name in names_or_groups:
expanded = TOOL_GROUPS.get(name, (name,))
for tool_name in expanded:
if tool_name in seen:
continue
seen.add(tool_name)
ordered.append(tool_name)
return tuple(ordered)
def _normalize_tool_names(tool_names: str | Iterable[str]) -> set[str]:
if isinstance(tool_names, str):
return set(resolve_tool_names(tool_names))
ordered: list[str] = []
for name in tool_names:
ordered.extend(resolve_tool_names(name))
return set(ordered)
def _event_weight(invocation: ToolInvocation, limit_mode: LimitMode) -> int:
if limit_mode == "calls":
return 1
return 2 if invocation.has_result else 1
def _as_record(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
return value
def _as_string(value: Any) -> str | None:
return value if isinstance(value, str) else None