Compare commits
2 Commits
fix/issue-
...
feat/runti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c16ac070 | ||
|
|
4abbd1fc14 |
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "hashline-edit-benchmark",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.3.0",
|
||||
"@friendliai/ai-provider": "^1.0.9",
|
||||
"ai": "^6.0.94",
|
||||
"zod": "^4.1.0",
|
||||
@@ -14,11 +15,13 @@
|
||||
"packages": {
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.55", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@1.3.24", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
|
||||
"@friendliai/ai-provider": ["@friendliai/ai-provider@1.1.4", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.12" } }, "sha512-9TU4B1QFqPhbkONjI5afCF7Ox4jOqtGg1xw8mA9QHZdtlEbZxU+mBNvMPlI5pU5kPoN6s7wkXmFmxpID+own1A=="],
|
||||
|
||||
@@ -34,6 +37,26 @@
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
|
||||
"@friendliai/ai-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@friendliai/ai-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
|
||||
"ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
"bench:all": "bun run bench:basic && bun run bench:edge"
|
||||
},
|
||||
"dependencies": {
|
||||
"@friendliai/ai-provider": "^1.0.9",
|
||||
"ai": "^6.0.94",
|
||||
"@ai-sdk/openai": "^1.3.0",
|
||||
"@friendliai/ai-provider": "^1.0.9",
|
||||
"zod": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
54
bun.lock
54
bun.lock
@@ -5,13 +5,13 @@
|
||||
"": {
|
||||
"name": "oh-my-opencode",
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.41.1",
|
||||
"@ast-grep/napi": "^0.41.1",
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@opencode-ai/plugin": "^1.2.24",
|
||||
"@opencode-ai/sdk": "^1.2.24",
|
||||
"@opencode-ai/plugin": "^1.2.16",
|
||||
"@opencode-ai/sdk": "^1.2.17",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"diff": "^8.0.3",
|
||||
@@ -25,7 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "1.3.10",
|
||||
"bun-types": "1.3.6",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -49,44 +49,44 @@
|
||||
"@code-yeongyu/comment-checker",
|
||||
],
|
||||
"overrides": {
|
||||
"@opencode-ai/sdk": "^1.2.24",
|
||||
"@opencode-ai/sdk": "^1.2.17",
|
||||
},
|
||||
"packages": {
|
||||
"@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="],
|
||||
"@ast-grep/cli": ["@ast-grep/cli@0.40.5", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.5", "@ast-grep/cli-darwin-x64": "0.40.5", "@ast-grep/cli-linux-arm64-gnu": "0.40.5", "@ast-grep/cli-linux-x64-gnu": "0.40.5", "@ast-grep/cli-win32-arm64-msvc": "0.40.5", "@ast-grep/cli-win32-ia32-msvc": "0.40.5", "@ast-grep/cli-win32-x64-msvc": "0.40.5" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-yVXL7Gz0WIHerQLf+MVaVSkhIhidtWReG5akNVr/JS9OVCVkSdz7gWm7H8jVv2M9OO1tauuG76K3UaRGBPu5lQ=="],
|
||||
|
||||
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.41.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-30lrXtyDB+16WS89Bk8sufA5TVUczyQye4PoIYLxZr+PRbPW7thpxHwBwGWL6QvPvUtlElrCe4seA1CEwFxeFA=="],
|
||||
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.40.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T9CzwJ1GqQhnANdsu6c7iT1akpvTVMK+AZrxnhIPv33Ze5hrXUUkqan+j4wUAukRJDqU7u94EhXLSLD+5tcJ8g=="],
|
||||
|
||||
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.41.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jRft57aWRgqYgLXooWxS9Nx5mb5JJ/KQIwEqacWkcmDZEdEui7oG50//6y4/vU5WRcS1n6oB2Vs7WBvTh3/Ypg=="],
|
||||
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.40.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ez9b2zKvXU8f4ghhjlqYvbx6tWCKJTuVlNVqDDfjqwwhGeiTYfnzMlSVat4ElYRMd21gLtXZIMy055v2f21Ztg=="],
|
||||
|
||||
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1XUL+8u+Xs1FoM2W6F4v8pRa2aQQcp5CZXBG8uy9n8FhwsQtrhBclJ2Vr9g/zzswHQT1293mnP5TOk1wlYZq6w=="],
|
||||
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-VXa2L1IEYD66AMb0GuG7VlMMbPmEGoJUySWDcwSZo/D9neiry3MJ41LQR5oTG2HyhIPBsf9umrXnmuRq66BviA=="],
|
||||
|
||||
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oSsbXzbcl4hnRAw7b1bTFZapx9s+O8ToJJKI44oJAb7xKIG3Rubn2IMBOFvMvjjWEEax8PpS2IocgdB8nUAcbA=="],
|
||||
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-GQC5162eIOWXR2eQQ6Knzg7/8Trp5E1ODJkaErf0IubdQrZBGqj5AAcQPcWgPbbnmktjIp0H4NraPpOJ9eJ22A=="],
|
||||
|
||||
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.41.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-jTMNqjXnQUhInMB1X06sxWZJv/6pd4/iYSyk8RR5kdulnuNzoGEB9KYbm6ojxktPtMfZpb+7eShQLqqy/dG6Ag=="],
|
||||
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.40.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-YiZdnQZsSlXQTMsZJop/Ux9MmUGfuRvC2x/UbFgrt5OBSYxND+yoiMc0WcA3WG+wU+tt4ZkB5HUea3r/IkOLYA=="],
|
||||
|
||||
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.41.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-mCTyr6/KQneKk0iYaWup4ywW5buNcFqL6TrJVfU0tkd38fu/RtJ5zywr978vVvFxsY+urRU0qkrmtQqXQNwDFA=="],
|
||||
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.40.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-MHkCxCITVTr8sY9CcVqNKbfUzMa3Hc6IilGXad0Clnw2vNmPfWqSky+hU/UTerr5YHWwWfAVURH7ANZgirtx0Q=="],
|
||||
|
||||
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-AUbR67UKWsfgyy3SWQq258ZB0xSlaAe15Gl5hPu5tbUu4HTt6rKrUCTEEubYgbNdPPZWtxjobjFjMsDTWfnrug=="],
|
||||
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/MJ5un7yxlClaaxou9eYl+Kr2xr/yTtYtTq5aLBWjPWA6dmmJ1nAJgx5zKHVuplFXFBrFDQk3paEgAETMTGcrA=="],
|
||||
|
||||
"@ast-grep/napi": ["@ast-grep/napi@0.41.1", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.41.1", "@ast-grep/napi-darwin-x64": "0.41.1", "@ast-grep/napi-linux-arm64-gnu": "0.41.1", "@ast-grep/napi-linux-arm64-musl": "0.41.1", "@ast-grep/napi-linux-x64-gnu": "0.41.1", "@ast-grep/napi-linux-x64-musl": "0.41.1", "@ast-grep/napi-win32-arm64-msvc": "0.41.1", "@ast-grep/napi-win32-ia32-msvc": "0.41.1", "@ast-grep/napi-win32-x64-msvc": "0.41.1" } }, "sha512-OYQVWBbb43af2lTSCayMS7wsZ20nl+fw6LGVl/5zSuHTZRNfANknKLk3wMA4y7RIaAiIwrldAmI6GNZeIDRTkQ=="],
|
||||
"@ast-grep/napi": ["@ast-grep/napi@0.40.5", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.5", "@ast-grep/napi-darwin-x64": "0.40.5", "@ast-grep/napi-linux-arm64-gnu": "0.40.5", "@ast-grep/napi-linux-arm64-musl": "0.40.5", "@ast-grep/napi-linux-x64-gnu": "0.40.5", "@ast-grep/napi-linux-x64-musl": "0.40.5", "@ast-grep/napi-win32-arm64-msvc": "0.40.5", "@ast-grep/napi-win32-ia32-msvc": "0.40.5", "@ast-grep/napi-win32-x64-msvc": "0.40.5" } }, "sha512-hJA62OeBKUQT68DD2gDyhOqJxZxycqg8wLxbqjgqSzYttCMSDL9tiAQ9abgekBYNHudbJosm9sWOEbmCDfpX2A=="],
|
||||
|
||||
"@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.41.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sZHwg/oD6YB2y4VD8ZMeMHBq/ONil+mx+bB61YAiGQB+8UCMSFxJupvtNICB/BnIFqcPCVz/jCaSdbASLrbXQQ=="],
|
||||
"@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.40.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2F072fGN0WTq7KI3okuEnkGJVEHLbi56Bw1H6NAMf7j2mJJeQWsRyGOMcyNnUXZDeNdvoMH0OB2a5wwUegY/nQ=="],
|
||||
|
||||
"@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.41.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-SL9hGB8sKvPnLUcigiDQrhohL7N4ujy1+t885kGcBkMXR73JT05OpPmvw0AWmg8l2iH1e5uNK/ZjnV/lSkynxQ=="],
|
||||
"@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.40.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-dJMidHZhhxuLBYNi6/FKI812jQ7wcFPSKkVPwviez2D+KvYagapUMAV/4dJ7FCORfguVk8Y0jpPAlYmWRT5nvA=="],
|
||||
|
||||
"@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mkNQpkm1jvnIdeRMnEWZ4Q0gNGApoNTMAoJRVmY11CkA4C/vIdNIjxj7UB61xV42Ng/A7Fw8mQUQuFos0lAKPQ=="],
|
||||
"@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-nBRCbyoS87uqkaw4Oyfe5VO+SRm2B+0g0T8ME69Qry9ShMf41a2bTdpcQx9e8scZPogq+CTwDHo3THyBV71l9w=="],
|
||||
|
||||
"@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-0G3cHyc+8A945aLie55bLZ+oaEBer0EFlyP/GlwRAx4nn5vGBct1hVTxSexWJ6AxnnRNPlN0mvswVwXiE7H7gA=="],
|
||||
"@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA=="],
|
||||
|
||||
"@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+aNiCik3iTMtUrMp1k2yIMjby1U64EydTH1qotlx+fh8YvwrwwxZWct7NlurY3MILgT/WONSxhHKmL5NsbB4dw=="],
|
||||
"@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q=="],
|
||||
|
||||
"@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rBrZSx5za3OliYcJcUrbLct+1+8oxh8ZEjYPiLCybe4FhspNKGM952g8a4sjgRuwbKS9BstYO9Fz+wthFnaFUQ=="],
|
||||
"@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg=="],
|
||||
|
||||
"@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.41.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-uNRHM3a1qFN0SECJDCEDVy1b0N75JNhJE2O/2BhDkDo0qM8kEewf9jRtG1fwpgZbMK2KoKvMHU/KQ73fWN44Zw=="],
|
||||
"@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.40.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug=="],
|
||||
|
||||
"@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.41.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-uNPQwGUBGIbCX+WhEIfYJf/VrS7o5+vJvT4MVEHI8aVJnpjcFsLrFI0hIv044OXxnleOo2HUvEmjOrub//at/Q=="],
|
||||
"@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.40.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-K/u8De62iUnFCzVUs7FBdTZ2Jrgc5/DLHqjpup66KxZ7GIM9/HGME/O8aSoPkpcAeCD4TiTZ11C1i5p5H98hTg=="],
|
||||
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xFp68OCUEmWYcqoreZFaf2xwMhm/22Qf6bR2Qyn8WNVY9RF4m4+k5K+7Wn+n9xy0vHUPhtFd1So/SvuaqLHEoA=="],
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-dqm5zg/o4Nh4VOQPEpMS23ot8HVd22gG0eg01t4CFcZeuzyuSgBlOL3N7xLbz3iH2sVkk7keuBwAzOIpTqziNQ=="],
|
||||
|
||||
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
|
||||
|
||||
@@ -98,9 +98,9 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.24", "", { "dependencies": { "@opencode-ai/sdk": "1.2.24", "zod": "4.1.8" } }, "sha512-B3hw415D+2w6AtdRdvKWkuQVT0LXDWTdnAZhZC6gbd+UHh5O5DMmnZTe/YM8yK8ZZO9Dvo5rnV78TdDDYunJiw=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.16", "", { "dependencies": { "@opencode-ai/sdk": "1.2.16", "zod": "4.1.8" } }, "sha512-9Kb7BQIC2P3oKCvI8K3thP5YP0vE7yLvcmBmgyACUIqc3e5UL6U+4umLpTvgQa2eQdjxtOXznuGTNwgcGMHUHg=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.24", "", {}, "sha512-MQamFkRl4B/3d6oIRLNpkYR2fcwet1V/ffKyOKJXWjtP/CT9PDJMtLpu6olVHjXKQi8zMNltwuMhv1QsNtRlZg=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.17", "", {}, "sha512-HdeLeyJ2/Yl/NBHqw9pGFBnkIXuf0Id1kX1GMXDcnZwbJROUJ6TtrW/wLngTYW478E4CCm1jwknjxxmDuxzVMQ=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
|
||||
@@ -566,6 +566,7 @@ Auto-switches to backup models on API errors.
|
||||
"runtime_fallback": {
|
||||
"enabled": true,
|
||||
"retry_on_errors": [400, 429, 503, 529],
|
||||
"retry_on_message_patterns": ["no\\s+available\\s+accounts?"],
|
||||
"max_fallback_attempts": 3,
|
||||
"cooldown_seconds": 60,
|
||||
"timeout_seconds": 30,
|
||||
@@ -578,6 +579,7 @@ Auto-switches to backup models on API errors.
|
||||
| ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `enabled` | `false` | Enable runtime fallback |
|
||||
| `retry_on_errors` | `[400,429,503,529]` | HTTP codes that trigger fallback. Also handles classified provider key errors. |
|
||||
| `retry_on_message_patterns` | `[]` | Regex pattern strings matched against provider messages. Use this for provider-specific retry text that does not include status codes. |
|
||||
| `max_fallback_attempts` | `3` | Max fallback attempts per session (1–20) |
|
||||
| `cooldown_seconds` | `60` | Seconds before retrying a failed model |
|
||||
| `timeout_seconds` | `30` | Seconds before forcing next fallback. **Set to `0` to disable timeout-based escalation and provider retry message detection.** |
|
||||
|
||||
12
package.json
12
package.json
@@ -52,13 +52,13 @@
|
||||
},
|
||||
"homepage": "https://github.com/code-yeongyu/oh-my-openagent#readme",
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.41.1",
|
||||
"@ast-grep/napi": "^0.41.1",
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@opencode-ai/plugin": "^1.2.24",
|
||||
"@opencode-ai/sdk": "^1.2.24",
|
||||
"@opencode-ai/plugin": "^1.2.16",
|
||||
"@opencode-ai/sdk": "^1.2.17",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"diff": "^8.0.3",
|
||||
@@ -72,7 +72,7 @@
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "1.3.10",
|
||||
"bun-types": "1.3.6",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -89,7 +89,7 @@
|
||||
"oh-my-opencode-windows-x64-baseline": "3.11.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@opencode-ai/sdk": "^1.2.24"
|
||||
"@opencode-ai/sdk": "^1.2.17"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -2055,54 +2055,6 @@
|
||||
"created_at": "2026-03-09T03:02:18Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2399
|
||||
},
|
||||
{
|
||||
"name": "zengxiaolou",
|
||||
"id": 44358506,
|
||||
"comment_id": 4031110903,
|
||||
"created_at": "2026-03-10T12:43:21Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2433
|
||||
},
|
||||
{
|
||||
"name": "cphoward",
|
||||
"id": 3116760,
|
||||
"comment_id": 4033869380,
|
||||
"created_at": "2026-03-10T19:22:48Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2437
|
||||
},
|
||||
{
|
||||
"name": "hehe226",
|
||||
"id": 80147109,
|
||||
"comment_id": 4035596903,
|
||||
"created_at": "2026-03-11T01:43:13Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2438
|
||||
},
|
||||
{
|
||||
"name": "tc9011",
|
||||
"id": 18380140,
|
||||
"comment_id": 4035807053,
|
||||
"created_at": "2026-03-11T02:43:17Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2443
|
||||
},
|
||||
{
|
||||
"name": "zztdandan",
|
||||
"id": 24284382,
|
||||
"comment_id": 4035969667,
|
||||
"created_at": "2026-03-11T03:27:20Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2444
|
||||
},
|
||||
{
|
||||
"name": "win0na",
|
||||
"id": 4269491,
|
||||
"comment_id": 4036781426,
|
||||
"created_at": "2026-03-11T06:16:22Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 2446
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { createMetisAgent, metisPromptMetadata } from "./metis"
|
||||
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
||||
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import { createSisyphusJuniorAgentWithOverrides } from "./sisyphus-junior"
|
||||
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||
import {
|
||||
fetchAvailableModels,
|
||||
@@ -42,7 +41,6 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
// Note: Atlas is handled specially in createBuiltinAgents()
|
||||
// because it needs OrchestratorContext, not just a model string
|
||||
atlas: createAtlasAgent as AgentFactory,
|
||||
"sisyphus-junior": createSisyphusJuniorAgentWithOverrides as unknown as AgentFactory,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,7 +50,6 @@ export function collectPendingBuiltinAgents(input: {
|
||||
if (agentName === "sisyphus") continue
|
||||
if (agentName === "hephaestus") continue
|
||||
if (agentName === "atlas") continue
|
||||
if (agentName === "sisyphus-junior") continue
|
||||
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
|
||||
|
||||
const override = agentOverrides[agentName]
|
||||
|
||||
@@ -2,4 +2,3 @@ export * from "./types"
|
||||
export { createBuiltinAgents } from "./builtin-agents"
|
||||
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
|
||||
export type { PrometheusPromptSource } from "./prometheus"
|
||||
export { createSisyphusJuniorAgentWithOverrides, SISYPHUS_JUNIOR_DEFAULTS } from "./sisyphus-junior"
|
||||
|
||||
@@ -113,8 +113,7 @@ export type BuiltinAgentName =
|
||||
| "multimodal-looker"
|
||||
| "metis"
|
||||
| "momus"
|
||||
| "atlas"
|
||||
| "sisyphus-junior";
|
||||
| "atlas";
|
||||
|
||||
export type OverridableAgentName = "build" | BuiltinAgentName;
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export const BuiltinAgentNameSchema = z.enum([
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas",
|
||||
"sisyphus-junior",
|
||||
])
|
||||
|
||||
export const BuiltinSkillNameSchema = z.enum([
|
||||
|
||||
@@ -5,6 +5,7 @@ export const RuntimeFallbackConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
/** HTTP status codes that trigger fallback (default: [400, 429, 503, 529]) */
|
||||
retry_on_errors: z.array(z.number()).optional(),
|
||||
retry_on_message_patterns: z.array(z.string()).optional(),
|
||||
/** Maximum fallback attempts per session (default: 3) */
|
||||
max_fallback_attempts: z.number().min(1).max(20).optional(),
|
||||
/** Cooldown in seconds before retrying a failed model (default: 60) */
|
||||
|
||||
@@ -12,12 +12,25 @@ import type { RuntimeFallbackConfig } from "../../config"
|
||||
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
|
||||
enabled: false,
|
||||
retry_on_errors: [429, 500, 502, 503, 504],
|
||||
retry_on_message_patterns: [],
|
||||
max_fallback_attempts: 3,
|
||||
cooldown_seconds: 60,
|
||||
timeout_seconds: 30,
|
||||
notify_on_fallback: true,
|
||||
}
|
||||
|
||||
export const AUTO_RETRY_SIGNAL_KEYWORD_PATTERNS = [
|
||||
"too\\s+many\\s+requests",
|
||||
"quota\\s*exceeded",
|
||||
"quota\\s+will\\s+reset\\s+after",
|
||||
"usage\\s+limit",
|
||||
"rate\\s+limit",
|
||||
"limit\\s+reached",
|
||||
"all\\s+credentials\\s+for\\s+model",
|
||||
"cool(?:ing)?\\s*down",
|
||||
"exhausted\\s+your\\s+capacity",
|
||||
]
|
||||
|
||||
/**
|
||||
* Error patterns that indicate rate limiting or temporary failures
|
||||
* These are checked in addition to HTTP status codes
|
||||
@@ -29,7 +42,6 @@ export const RETRYABLE_ERROR_PATTERNS = [
|
||||
/quota\s+will\s+reset\s+after/i,
|
||||
/all\s+credentials\s+for\s+model/i,
|
||||
/cool(?:ing)?\s+down/i,
|
||||
/cooldown/i,
|
||||
/exhausted\s+your\s+capacity/i,
|
||||
/usage\s+limit\s+has\s+been\s+reached/i,
|
||||
/service.?unavailable/i,
|
||||
|
||||
@@ -57,4 +57,43 @@ describe("runtime-fallback error classifier", () => {
|
||||
//#then
|
||||
expect(signal).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not classify no-available-accounts without configured message pattern", () => {
|
||||
//#given
|
||||
const info = {
|
||||
status: "No available accounts: no available accounts [retrying in 25s attempt #5]",
|
||||
}
|
||||
|
||||
//#when
|
||||
const signal = extractAutoRetrySignal(info)
|
||||
|
||||
//#then
|
||||
expect(signal).toBeUndefined()
|
||||
})
|
||||
|
||||
test("classifies no-available-accounts when configured message pattern is provided", () => {
|
||||
//#given
|
||||
const info = {
|
||||
status: "No available accounts: no available accounts [retrying in 25s attempt #5]",
|
||||
}
|
||||
|
||||
//#when
|
||||
const signal = extractAutoRetrySignal(info, ["no\\s+available\\s+accounts?"])
|
||||
|
||||
//#then
|
||||
expect(signal).toBeDefined()
|
||||
})
|
||||
|
||||
test("treats configured message pattern matches as retryable errors", () => {
|
||||
//#given
|
||||
const error = {
|
||||
message: "No available accounts for provider anthropic",
|
||||
}
|
||||
|
||||
//#when
|
||||
const retryable = isRetryableError(error, [429, 503, 529], ["no\\s+available\\s+accounts?"])
|
||||
|
||||
//#then
|
||||
expect(retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS } from "./constants"
|
||||
import { AUTO_RETRY_SIGNAL_KEYWORD_PATTERNS, DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS } from "./constants"
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (!error) return ""
|
||||
@@ -99,13 +99,30 @@ export interface AutoRetrySignal {
|
||||
signal: string
|
||||
}
|
||||
|
||||
export const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
|
||||
(combined) => /retrying\s+in/i.test(combined),
|
||||
(combined) =>
|
||||
/(?:too\s+many\s+requests|quota\s*exceeded|quota\s+will\s+reset\s+after|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
|
||||
]
|
||||
function compilePatterns(patterns: string[]): RegExp[] {
|
||||
const compiled: RegExp[] = []
|
||||
for (const pattern of patterns) {
|
||||
try {
|
||||
compiled.push(new RegExp(pattern, "i"))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return compiled
|
||||
}
|
||||
|
||||
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
|
||||
function resolveAutoRetryKeywordPatterns(retryOnMessagePatterns: string[] = []): RegExp[] {
|
||||
return compilePatterns([...AUTO_RETRY_SIGNAL_KEYWORD_PATTERNS, ...retryOnMessagePatterns])
|
||||
}
|
||||
|
||||
function resolveRetryableMessagePatterns(retryOnMessagePatterns: string[] = []): RegExp[] {
|
||||
return [...RETRYABLE_ERROR_PATTERNS, ...compilePatterns(retryOnMessagePatterns)]
|
||||
}
|
||||
|
||||
export function extractAutoRetrySignal(
|
||||
info: Record<string, unknown> | undefined,
|
||||
retryOnMessagePatterns: string[] = []
|
||||
): AutoRetrySignal | undefined {
|
||||
if (!info) return undefined
|
||||
|
||||
const candidates: string[] = []
|
||||
@@ -125,7 +142,12 @@ export function extractAutoRetrySignal(info: Record<string, unknown> | undefined
|
||||
const combined = candidates.join("\n")
|
||||
if (!combined) return undefined
|
||||
|
||||
const isAutoRetry = AUTO_RETRY_PATTERNS.every((test) => test(combined))
|
||||
const autoRetryPatterns: Array<(combined: string) => boolean> = [
|
||||
(text) => /retrying\s+in/i.test(text),
|
||||
(text) => resolveAutoRetryKeywordPatterns(retryOnMessagePatterns).some((pattern) => pattern.test(text)),
|
||||
]
|
||||
|
||||
const isAutoRetry = autoRetryPatterns.every((test) => test(combined))
|
||||
if (isAutoRetry) {
|
||||
return { signal: combined }
|
||||
}
|
||||
@@ -148,7 +170,11 @@ export function containsErrorContent(
|
||||
return { hasError: false }
|
||||
}
|
||||
|
||||
export function isRetryableError(error: unknown, retryOnErrors: number[]): boolean {
|
||||
export function isRetryableError(
|
||||
error: unknown,
|
||||
retryOnErrors: number[],
|
||||
retryOnMessagePatterns: string[] = []
|
||||
): boolean {
|
||||
const statusCode = extractStatusCode(error, retryOnErrors)
|
||||
const message = getErrorMessage(error)
|
||||
const errorType = classifyErrorType(error)
|
||||
@@ -165,5 +191,5 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
|
||||
return true
|
||||
}
|
||||
|
||||
return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message))
|
||||
return resolveRetryableMessagePatterns(retryOnMessagePatterns).some((pattern) => pattern.test(message))
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import type { HookDeps } from "./types"
|
||||
import type { AutoRetryHelpers } from "./auto-retry"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError } from "./error-classifier"
|
||||
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal } from "./error-classifier"
|
||||
import { createFallbackState, prepareFallback } from "./fallback-state"
|
||||
import { getFallbackModelsForSession } from "./fallback-models"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { createSessionStatusHandler } from "./session-status-handler"
|
||||
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
|
||||
|
||||
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts } = deps
|
||||
const sessionStatusHandler = createSessionStatusHandler(deps, helpers)
|
||||
const sessionStatusRetryKeys = new Map<string, string>()
|
||||
|
||||
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
|
||||
@@ -35,7 +35,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
sessionRetryInFlight.delete(sessionID)
|
||||
sessionAwaitingFallbackResult.delete(sessionID)
|
||||
helpers.clearSessionFallbackTimeout(sessionID)
|
||||
sessionStatusHandler.clearRetryKey(sessionID)
|
||||
sessionStatusRetryKeys.delete(sessionID)
|
||||
SessionCategoryRegistry.remove(sessionID)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
errorType: classifyErrorType(error),
|
||||
})
|
||||
|
||||
if (!isRetryableError(error, config.retry_on_errors)) {
|
||||
if (!isRetryableError(error, config.retry_on_errors, config.retry_on_message_patterns)) {
|
||||
log(`[${HOOK_NAME}] Error not retryable, skipping fallback`, {
|
||||
sessionID,
|
||||
retryable: false,
|
||||
@@ -185,6 +185,91 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSessionStatus = async (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const status = props?.status as { type?: string; message?: string; attempt?: number } | undefined
|
||||
const agent = props?.agent as string | undefined
|
||||
const model = props?.model as string | undefined
|
||||
|
||||
if (!sessionID || status?.type !== "retry") return
|
||||
|
||||
const retryMessage = typeof status.message === "string" ? status.message : ""
|
||||
const retrySignal = extractAutoRetrySignal(
|
||||
{ status: retryMessage, message: retryMessage },
|
||||
config.retry_on_message_patterns
|
||||
)
|
||||
if (!retrySignal) return
|
||||
|
||||
const retryKey = `${extractRetryAttempt(status.attempt, retryMessage)}:${normalizeRetryStatusMessage(retryMessage)}`
|
||||
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {
|
||||
return
|
||||
}
|
||||
sessionStatusRetryKeys.set(sessionID, retryKey)
|
||||
|
||||
if (sessionRetryInFlight.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] session.status retry skipped — retry already in flight`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
||||
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
|
||||
if (fallbackModels.length === 0) return
|
||||
|
||||
let state = sessionStates.get(sessionID)
|
||||
if (!state) {
|
||||
const detectedAgent = resolvedAgent
|
||||
const agentConfig = detectedAgent
|
||||
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents]
|
||||
: undefined
|
||||
const inferredModel = model || (agentConfig?.model as string | undefined)
|
||||
if (!inferredModel) {
|
||||
log(`[${HOOK_NAME}] session.status retry missing model info, cannot fallback`, { sessionID })
|
||||
return
|
||||
}
|
||||
state = createFallbackState(inferredModel)
|
||||
sessionStates.set(sessionID, state)
|
||||
}
|
||||
sessionLastAccess.set(sessionID, Date.now())
|
||||
|
||||
if (state.pendingFallbackModel) {
|
||||
log(`[${HOOK_NAME}] session.status retry skipped (pending fallback in progress)`, {
|
||||
sessionID,
|
||||
pendingFallbackModel: state.pendingFallbackModel,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Detected provider auto-retry signal in session.status`, {
|
||||
sessionID,
|
||||
model: state.currentModel,
|
||||
retryAttempt: status.attempt,
|
||||
})
|
||||
|
||||
await helpers.abortSessionRequest(sessionID, "session.status.retry-signal")
|
||||
|
||||
const result = prepareFallback(sessionID, state, fallbackModels, config)
|
||||
if (result.success && config.notify_on_fallback) {
|
||||
await deps.ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Model Fallback",
|
||||
message: `Switching to ${result.newModel?.split("/").pop() || result.newModel} for next request`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (result.success && result.newModel) {
|
||||
await helpers.autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.status")
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
log(`[${HOOK_NAME}] Fallback preparation failed`, { sessionID, error: result.error })
|
||||
}
|
||||
}
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (!config.enabled) return
|
||||
|
||||
@@ -194,7 +279,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
|
||||
if (event.type === "session.stop") { await handleSessionStop(props); return }
|
||||
if (event.type === "session.idle") { handleSessionIdle(props); return }
|
||||
if (event.type === "session.status") { await sessionStatusHandler.handleSessionStatus(props); return }
|
||||
if (event.type === "session.status") { await handleSessionStatus(props); return }
|
||||
if (event.type === "session.error") { await handleSessionError(props); return }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createRuntimeFallbackHook(
|
||||
const config = {
|
||||
enabled: options?.config?.enabled ?? DEFAULT_CONFIG.enabled,
|
||||
retry_on_errors: options?.config?.retry_on_errors ?? DEFAULT_CONFIG.retry_on_errors,
|
||||
retry_on_message_patterns: options?.config?.retry_on_message_patterns ?? DEFAULT_CONFIG.retry_on_message_patterns,
|
||||
max_fallback_attempts: options?.config?.max_fallback_attempts ?? DEFAULT_CONFIG.max_fallback_attempts,
|
||||
cooldown_seconds: options?.config?.cooldown_seconds ?? DEFAULT_CONFIG.cooldown_seconds,
|
||||
timeout_seconds: options?.config?.timeout_seconds ?? DEFAULT_CONFIG.timeout_seconds,
|
||||
|
||||
@@ -255,6 +255,41 @@ describe("runtime-fallback", () => {
|
||||
expect(errorLog).toBeDefined()
|
||||
})
|
||||
|
||||
test("should trigger fallback when custom retry_on_message_patterns matches", async () => {
|
||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||
config: createMockConfig({
|
||||
notify_on_fallback: false,
|
||||
retry_on_message_patterns: ["no\\s+available\\s+accounts?"],
|
||||
}),
|
||||
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.4"]),
|
||||
})
|
||||
const sessionID = "test-session-custom-message-pattern"
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-6" } },
|
||||
},
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: {
|
||||
message: "No available accounts: no available accounts [retrying in 25s attempt #5]",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
|
||||
expect(fallbackLog).toBeDefined()
|
||||
expect(fallbackLog?.data).toMatchObject({ from: "anthropic/claude-opus-4-6", to: "openai/gpt-5.4" })
|
||||
})
|
||||
|
||||
test("should continue fallback chain when fallback model is not found", async () => {
|
||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
|
||||
@@ -52,7 +52,9 @@ export function hasVisibleAssistantResponse(extractAutoRetrySignalFn: typeof ext
|
||||
|
||||
export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
const { ctx, config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult } = deps
|
||||
const checkVisibleResponse = hasVisibleAssistantResponse(extractAutoRetrySignal)
|
||||
const checkVisibleResponse = hasVisibleAssistantResponse((info) =>
|
||||
extractAutoRetrySignal(info, config.retry_on_message_patterns)
|
||||
)
|
||||
|
||||
return async (props: Record<string, unknown> | undefined) => {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
@@ -61,14 +63,17 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
|
||||
const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined
|
||||
const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined
|
||||
const parts = eventParts && eventParts.length > 0 ? eventParts : infoParts
|
||||
const retrySignalResult = extractAutoRetrySignal(info)
|
||||
const retrySignalResult = extractAutoRetrySignal(info, config.retry_on_message_patterns)
|
||||
const partsText = (parts ?? [])
|
||||
.filter((p) => typeof p?.text === "string")
|
||||
.map((p) => (p.text ?? "").trim())
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n")
|
||||
const retrySignalFromParts = partsText
|
||||
? extractAutoRetrySignal({ message: partsText, status: partsText, summary: partsText })?.signal
|
||||
? extractAutoRetrySignal(
|
||||
{ message: partsText, status: partsText, summary: partsText },
|
||||
config.retry_on_message_patterns
|
||||
)?.signal
|
||||
: undefined
|
||||
const retrySignal = retrySignalResult?.signal ?? retrySignalFromParts
|
||||
const errorContentResult = containsErrorContent(parts)
|
||||
@@ -134,7 +139,7 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
|
||||
errorType: classifyErrorType(error),
|
||||
})
|
||||
|
||||
if (!isRetryableError(error, config.retry_on_errors)) {
|
||||
if (!isRetryableError(error, config.retry_on_errors, config.retry_on_message_patterns)) {
|
||||
log(`[${HOOK_NAME}] message.updated error not retryable, skipping fallback`, {
|
||||
sessionID,
|
||||
statusCode: extractStatusCode(error, config.retry_on_errors),
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import type { HookDeps } from "./types"
|
||||
import type { AutoRetryHelpers } from "./auto-retry"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isRetryableError } from "./error-classifier"
|
||||
import { createFallbackState, prepareFallback } from "./fallback-state"
|
||||
import { getFallbackModelsForSession } from "./fallback-models"
|
||||
import { extractRetryAttempt, extractRetryStatusModel, normalizeRetryStatusMessage } from "../../shared/retry-status-utils"
|
||||
|
||||
type SessionStatus = {
|
||||
type?: string
|
||||
message?: string
|
||||
attempt?: number
|
||||
}
|
||||
|
||||
function resolveInitialModel(
|
||||
props: Record<string, unknown> | undefined,
|
||||
retryMessage: string,
|
||||
resolvedAgent: string | undefined,
|
||||
pluginConfig: HookDeps["pluginConfig"],
|
||||
): string | undefined {
|
||||
const eventModel = typeof props?.model === "string" ? props.model : undefined
|
||||
if (eventModel) {
|
||||
return eventModel
|
||||
}
|
||||
|
||||
const retryModel = extractRetryStatusModel(retryMessage)
|
||||
if (retryModel) {
|
||||
return retryModel
|
||||
}
|
||||
|
||||
const agentConfig = resolvedAgent
|
||||
? pluginConfig?.agents?.[resolvedAgent as keyof typeof pluginConfig.agents]
|
||||
: undefined
|
||||
|
||||
return typeof agentConfig?.model === "string" ? agentConfig.model : undefined
|
||||
}
|
||||
|
||||
export function createSessionStatusHandler(deps: HookDeps, helpers: AutoRetryHelpers): {
|
||||
clearRetryKey: (sessionID: string) => void
|
||||
handleSessionStatus: (props: Record<string, unknown> | undefined) => Promise<void>
|
||||
} {
|
||||
const {
|
||||
config,
|
||||
pluginConfig,
|
||||
sessionStates,
|
||||
sessionLastAccess,
|
||||
sessionRetryInFlight,
|
||||
sessionAwaitingFallbackResult,
|
||||
} = deps
|
||||
const sessionStatusRetryKeys = new Map<string, string>()
|
||||
|
||||
const clearRetryKey = (sessionID: string): void => {
|
||||
sessionStatusRetryKeys.delete(sessionID)
|
||||
}
|
||||
|
||||
const handleSessionStatus = async (props: Record<string, unknown> | undefined): Promise<void> => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const status = props?.status as SessionStatus | undefined
|
||||
const agent = props?.agent as string | undefined
|
||||
const timeoutEnabled = config.timeout_seconds > 0
|
||||
|
||||
if (!sessionID || status?.type !== "retry" || !timeoutEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const retryMessage = typeof status.message === "string" ? status.message : ""
|
||||
if (!retryMessage || !isRetryableError({ message: retryMessage }, config.retry_on_errors)) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentState = sessionStates.get(sessionID)
|
||||
const retryAttempt = extractRetryAttempt(status.attempt, retryMessage)
|
||||
const retryModel =
|
||||
(typeof props?.model === "string" ? props.model : undefined) ??
|
||||
extractRetryStatusModel(retryMessage) ??
|
||||
currentState?.currentModel ??
|
||||
"unknown-model"
|
||||
const retryKey = `${retryAttempt}:${retryModel}:${normalizeRetryStatusMessage(retryMessage)}`
|
||||
|
||||
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {
|
||||
return
|
||||
}
|
||||
sessionStatusRetryKeys.set(sessionID, retryKey)
|
||||
|
||||
if (sessionRetryInFlight.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Overriding in-flight retry due to provider session.status retry signal`, {
|
||||
sessionID,
|
||||
retryModel,
|
||||
})
|
||||
await helpers.abortSessionRequest(sessionID, "session.status.retry-signal")
|
||||
sessionRetryInFlight.delete(sessionID)
|
||||
}
|
||||
|
||||
sessionAwaitingFallbackResult.delete(sessionID)
|
||||
|
||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
||||
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
|
||||
|
||||
if (fallbackModels.length === 0) {
|
||||
log(`[${HOOK_NAME}] No fallback models configured`, { sessionID, agent: resolvedAgent ?? agent })
|
||||
return
|
||||
}
|
||||
|
||||
let state = currentState
|
||||
if (!state) {
|
||||
const initialModel = resolveInitialModel(props, retryMessage, resolvedAgent, pluginConfig)
|
||||
if (!initialModel) {
|
||||
log(`[${HOOK_NAME}] session.status retry missing model info, cannot fallback`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
state = createFallbackState(initialModel)
|
||||
sessionStates.set(sessionID, state)
|
||||
}
|
||||
|
||||
sessionLastAccess.set(sessionID, Date.now())
|
||||
|
||||
if (state.pendingFallbackModel) {
|
||||
log(`[${HOOK_NAME}] Clearing pending fallback due to provider session.status retry signal`, {
|
||||
sessionID,
|
||||
pendingFallbackModel: state.pendingFallbackModel,
|
||||
})
|
||||
state.pendingFallbackModel = undefined
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Detected provider auto-retry signal in session.status`, {
|
||||
sessionID,
|
||||
model: state.currentModel,
|
||||
retryAttempt,
|
||||
})
|
||||
|
||||
const result = prepareFallback(sessionID, state, fallbackModels, config)
|
||||
|
||||
if (result.success && config.notify_on_fallback) {
|
||||
await deps.ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Model Fallback",
|
||||
message: `Switching to ${result.newModel?.split("/").pop() || result.newModel} for next request`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (result.success && result.newModel) {
|
||||
await helpers.autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.status")
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Fallback preparation failed`, { sessionID, error: result.error })
|
||||
}
|
||||
|
||||
return {
|
||||
clearRetryKey,
|
||||
handleSessionStatus,
|
||||
}
|
||||
}
|
||||
@@ -158,13 +158,6 @@ export function createChatMessageHandler(args: {
|
||||
}
|
||||
}
|
||||
|
||||
await applyUltraworkModelOverrideOnMessage(
|
||||
pluginConfig,
|
||||
input.agent,
|
||||
output,
|
||||
pluginContext.client.tui,
|
||||
input.sessionID,
|
||||
pluginContext.client,
|
||||
)
|
||||
applyUltraworkModelOverrideOnMessage(pluginConfig, input.agent, output, pluginContext.client.tui, input.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getAgentConfigKey } from "../shared/agent-display-names";
|
||||
import { log } from "../shared/logger";
|
||||
import { shouldRetryError } from "../shared/model-error-classifier";
|
||||
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
|
||||
import { extractRetryAttempt, extractRetryStatusModel, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
|
||||
import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
|
||||
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
|
||||
import { deleteSessionTools } from "../shared/session-tools-store";
|
||||
import { lspManager } from "../tools";
|
||||
@@ -387,14 +387,11 @@ export function createEventHandler(args: {
|
||||
if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) {
|
||||
try {
|
||||
const retryMessage = typeof status.message === "string" ? status.message : "";
|
||||
const parsedForKey = extractProviderModelFromErrorMessage(retryMessage);
|
||||
const retryAttempt = extractRetryAttempt(status.attempt, retryMessage);
|
||||
const retryModel =
|
||||
extractRetryStatusModel(retryMessage) ??
|
||||
lastKnownModelBySession.get(sessionID)?.modelID ??
|
||||
"unknown-model";
|
||||
// Deduplicate countdown updates for the same retry attempt/model.
|
||||
// Messages like "retrying in 7m 56s" change every second but should only trigger once.
|
||||
const retryKey = `${retryAttempt}:${retryModel}:${normalizeRetryStatusMessage(retryMessage)}`;
|
||||
const retryKey = `${retryAttempt}:${parsedForKey.providerID ?? ""}/${parsedForKey.modelID ?? ""}:${normalizeRetryStatusMessage(retryMessage)}`;
|
||||
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { cpSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
|
||||
|
||||
const tempDirectories: string[] = []
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getNestedRecord(record: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
|
||||
const value = record[key]
|
||||
return isRecord(value) ? value : undefined
|
||||
}
|
||||
|
||||
async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
|
||||
const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir))
|
||||
const sourceZodDirectory = join(pluginPackageDirectory, "node_modules", "zod")
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "omo-host-zod-"))
|
||||
const copiedZodDirectory = join(tempDirectory, "zod")
|
||||
|
||||
cpSync(sourceZodDirectory, copiedZodDirectory, { recursive: true })
|
||||
tempDirectories.push(tempDirectory)
|
||||
|
||||
return await import(pathToFileURL(join(copiedZodDirectory, "index.js")).href)
|
||||
}
|
||||
|
||||
function serializeWithHostZod(
|
||||
hostZod: typeof import("zod"),
|
||||
args: Record<string, object>,
|
||||
): Record<string, unknown> {
|
||||
return hostZod.z.toJSONSchema(Reflect.apply(hostZod.z.object, hostZod.z, [args]))
|
||||
}
|
||||
|
||||
describe("normalizeToolArgSchemas", () => {
|
||||
afterEach(() => {
|
||||
for (const tempDirectory of tempDirectories.splice(0)) {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves nested descriptions and metadata across zod instances", async () => {
|
||||
// given
|
||||
const hostZod = await loadSeparateHostZodModule()
|
||||
const toolDefinition = tool({
|
||||
description: "Search tool",
|
||||
args: {
|
||||
filters: tool.schema
|
||||
.object({
|
||||
query: tool.schema
|
||||
.string()
|
||||
.describe("Free-text search query")
|
||||
.meta({ title: "Query", examples: ["issue 2314"] }),
|
||||
})
|
||||
.describe("Filter options")
|
||||
.meta({ title: "Filters" }),
|
||||
},
|
||||
async execute(): Promise<string> {
|
||||
return "ok"
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const beforeSchema = serializeWithHostZod(hostZod, toolDefinition.args)
|
||||
const beforeProperties = getNestedRecord(beforeSchema, "properties")
|
||||
const beforeFilters = beforeProperties ? getNestedRecord(beforeProperties, "filters") : undefined
|
||||
const beforeFilterProperties = beforeFilters ? getNestedRecord(beforeFilters, "properties") : undefined
|
||||
const beforeQuery = beforeFilterProperties ? getNestedRecord(beforeFilterProperties, "query") : undefined
|
||||
|
||||
normalizeToolArgSchemas(toolDefinition)
|
||||
|
||||
const afterSchema = serializeWithHostZod(hostZod, toolDefinition.args)
|
||||
const afterProperties = getNestedRecord(afterSchema, "properties")
|
||||
const afterFilters = afterProperties ? getNestedRecord(afterProperties, "filters") : undefined
|
||||
const afterFilterProperties = afterFilters ? getNestedRecord(afterFilters, "properties") : undefined
|
||||
const afterQuery = afterFilterProperties ? getNestedRecord(afterFilterProperties, "query") : undefined
|
||||
|
||||
// then
|
||||
expect(beforeFilters?.description).toBeUndefined()
|
||||
expect(beforeFilters?.title).toBeUndefined()
|
||||
expect(beforeQuery?.description).toBeUndefined()
|
||||
expect(beforeQuery?.title).toBeUndefined()
|
||||
expect(beforeQuery?.examples).toBeUndefined()
|
||||
|
||||
expect(afterFilters?.description).toBe("Filter options")
|
||||
expect(afterFilters?.title).toBe("Filters")
|
||||
expect(afterQuery?.description).toBe("Free-text search query")
|
||||
expect(afterQuery?.title).toBe("Query")
|
||||
expect(afterQuery?.examples).toEqual(["issue 2314"])
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import type { ToolDefinition } from "@opencode-ai/plugin"
|
||||
|
||||
type ToolArgSchema = ToolDefinition["args"][string]
|
||||
|
||||
type SchemaWithJsonSchemaOverride = ToolArgSchema & {
|
||||
_zod: ToolArgSchema["_zod"] & {
|
||||
toJSONSchema?: () => unknown
|
||||
}
|
||||
}
|
||||
|
||||
function stripRootJsonSchemaFields(jsonSchema: Record<string, unknown>): Record<string, unknown> {
|
||||
const { $schema: _schema, ...rest } = jsonSchema
|
||||
return rest
|
||||
}
|
||||
|
||||
function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void {
|
||||
if (schema._zod.toJSONSchema) {
|
||||
return
|
||||
}
|
||||
|
||||
schema._zod.toJSONSchema = (): Record<string, unknown> => {
|
||||
const originalOverride = schema._zod.toJSONSchema
|
||||
delete schema._zod.toJSONSchema
|
||||
|
||||
try {
|
||||
return stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema))
|
||||
} finally {
|
||||
schema._zod.toJSONSchema = originalOverride
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeToolArgSchemas<TDefinition extends Pick<ToolDefinition, "args">>(
|
||||
toolDefinition: TDefinition,
|
||||
): TDefinition {
|
||||
for (const schema of Object.values(toolDefinition.args)) {
|
||||
attachJsonSchemaOverride(schema)
|
||||
}
|
||||
|
||||
return toolDefinition
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { OhMyOpenCodeConfigSchema } from "../config"
|
||||
import * as mcpLoader from "../features/claude-code-mcp-loader"
|
||||
import * as skillLoader from "../features/opencode-skill-loader"
|
||||
import { createSkillContext } from "./skill-context"
|
||||
|
||||
describe("createSkillContext", () => {
|
||||
const testDirectory = join(tmpdir(), `skill-context-test-${Date.now()}`)
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("excludes discovered playwright skill when browser provider is agent-browser", async () => {
|
||||
// given
|
||||
const discoveredPlaywrightDir = join(testDirectory, ".claude", "skills", "playwright")
|
||||
mkdirSync(discoveredPlaywrightDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(discoveredPlaywrightDir, "SKILL.md"),
|
||||
[
|
||||
"---",
|
||||
"name: playwright",
|
||||
"description: Discovered playwright skill",
|
||||
"---",
|
||||
"Discovered playwright body.",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const discoverConfigSourceSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverConfigSourceSkills",
|
||||
).mockResolvedValue([])
|
||||
const discoverUserClaudeSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverUserClaudeSkills",
|
||||
).mockResolvedValue([])
|
||||
const discoverOpencodeGlobalSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverOpencodeGlobalSkills",
|
||||
).mockResolvedValue([])
|
||||
const discoverProjectAgentsSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverProjectAgentsSkills",
|
||||
).mockResolvedValue([])
|
||||
const discoverGlobalAgentsSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverGlobalAgentsSkills",
|
||||
).mockResolvedValue([])
|
||||
const getSystemMcpServerNamesSpy = spyOn(
|
||||
mcpLoader,
|
||||
"getSystemMcpServerNames",
|
||||
).mockReturnValue(new Set<string>())
|
||||
|
||||
const pluginConfig = OhMyOpenCodeConfigSchema.parse({
|
||||
browser_automation_engine: { provider: "agent-browser" },
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
const result = await createSkillContext({
|
||||
directory: testDirectory,
|
||||
pluginConfig,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result.browserProvider).toBe("agent-browser")
|
||||
expect(result.mergedSkills.some((skill) => skill.name === "agent-browser")).toBe(true)
|
||||
expect(result.mergedSkills.some((skill) => skill.name === "playwright")).toBe(false)
|
||||
expect(result.availableSkills.some((skill) => skill.name === "playwright")).toBe(false)
|
||||
} finally {
|
||||
discoverConfigSourceSkillsSpy.mockRestore()
|
||||
discoverUserClaudeSkillsSpy.mockRestore()
|
||||
discoverOpencodeGlobalSkillsSpy.mockRestore()
|
||||
discoverProjectAgentsSkillsSpy.mockRestore()
|
||||
discoverGlobalAgentsSkillsSpy.mockRestore()
|
||||
getSystemMcpServerNamesSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -26,27 +26,12 @@ export type SkillContext = {
|
||||
disabledSkills: Set<string>
|
||||
}
|
||||
|
||||
const PROVIDER_GATED_SKILL_NAMES = new Set(["agent-browser", "playwright"])
|
||||
|
||||
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
|
||||
if (scope === "user" || scope === "opencode") return "user"
|
||||
if (scope === "project" || scope === "opencode-project") return "project"
|
||||
return "plugin"
|
||||
}
|
||||
|
||||
function filterProviderGatedSkills(
|
||||
skills: LoadedSkill[],
|
||||
browserProvider: BrowserAutomationProvider,
|
||||
): LoadedSkill[] {
|
||||
return skills.filter((skill) => {
|
||||
if (!PROVIDER_GATED_SKILL_NAMES.has(skill.name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return skill.name === browserProvider
|
||||
})
|
||||
}
|
||||
|
||||
export async function createSkillContext(args: {
|
||||
directory: string
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
@@ -86,34 +71,14 @@ export async function createSkillContext(args: {
|
||||
discoverGlobalAgentsSkills(),
|
||||
])
|
||||
|
||||
const filteredConfigSourceSkills = filterProviderGatedSkills(
|
||||
configSourceSkills,
|
||||
browserProvider,
|
||||
)
|
||||
const filteredUserSkills = filterProviderGatedSkills(userSkills, browserProvider)
|
||||
const filteredGlobalSkills = filterProviderGatedSkills(globalSkills, browserProvider)
|
||||
const filteredProjectSkills = filterProviderGatedSkills(projectSkills, browserProvider)
|
||||
const filteredOpencodeProjectSkills = filterProviderGatedSkills(
|
||||
opencodeProjectSkills,
|
||||
browserProvider,
|
||||
)
|
||||
const filteredAgentsProjectSkills = filterProviderGatedSkills(
|
||||
agentsProjectSkills,
|
||||
browserProvider,
|
||||
)
|
||||
const filteredAgentsGlobalSkills = filterProviderGatedSkills(
|
||||
agentsGlobalSkills,
|
||||
browserProvider,
|
||||
)
|
||||
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
pluginConfig.skills,
|
||||
filteredConfigSourceSkills,
|
||||
[...filteredUserSkills, ...filteredAgentsGlobalSkills],
|
||||
filteredGlobalSkills,
|
||||
[...filteredProjectSkills, ...filteredAgentsProjectSkills],
|
||||
filteredOpencodeProjectSkills,
|
||||
configSourceSkills,
|
||||
[...userSkills, ...agentsGlobalSkills],
|
||||
globalSkills,
|
||||
[...projectSkills, ...agentsProjectSkills],
|
||||
opencodeProjectSkills,
|
||||
{ configDir: directory },
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import { log } from "../shared"
|
||||
|
||||
import type { Managers } from "../create-managers"
|
||||
import type { SkillContext } from "./skill-context"
|
||||
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
|
||||
|
||||
export type ToolRegistryResult = {
|
||||
filteredTools: ToolsRecord
|
||||
@@ -146,10 +145,6 @@ export function createToolRegistry(args: {
|
||||
...hashlineToolsRecord,
|
||||
}
|
||||
|
||||
for (const toolDefinition of Object.values(allTools)) {
|
||||
normalizeToolArgSchemas(toolDefinition)
|
||||
}
|
||||
|
||||
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getSessionAgent } from "../features/claude-code-session-state"
|
||||
import { log } from "../shared"
|
||||
import { getAgentConfigKey } from "../shared/agent-display-names"
|
||||
import { scheduleDeferredModelOverride } from "./ultrawork-db-model-override"
|
||||
import { resolveValidUltraworkVariant } from "./ultrawork-variant-availability"
|
||||
|
||||
const CODE_BLOCK = /```[\s\S]*?```/g
|
||||
const INLINE_CODE = /`[^`]+`/g
|
||||
@@ -16,7 +15,7 @@ export function detectUltrawork(text: string): boolean {
|
||||
}
|
||||
|
||||
function extractPromptText(parts: Array<{ type: string; text?: string }>): string {
|
||||
return parts.filter((part) => part.type === "text").map((part) => part.text || "").join("")
|
||||
return parts.filter((p) => p.type === "text").map((p) => p.text || "").join("")
|
||||
}
|
||||
|
||||
type ToastFn = {
|
||||
@@ -37,26 +36,22 @@ export type UltraworkOverrideResult = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
type ModelDescriptor = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
function isSameModel(current: unknown, target: ModelDescriptor): boolean {
|
||||
function isSameModel(
|
||||
current: unknown,
|
||||
target: { providerID: string; modelID: string },
|
||||
): boolean {
|
||||
if (typeof current !== "object" || current === null) return false
|
||||
const currentRecord = current as Record<string, unknown>
|
||||
return currentRecord["providerID"] === target.providerID && currentRecord["modelID"] === target.modelID
|
||||
}
|
||||
|
||||
function getMessageModel(current: unknown): ModelDescriptor | undefined {
|
||||
if (typeof current !== "object" || current === null) return undefined
|
||||
const currentRecord = current as Record<string, unknown>
|
||||
const providerID = currentRecord["providerID"]
|
||||
const modelID = currentRecord["modelID"]
|
||||
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
||||
return { providerID, modelID }
|
||||
return (
|
||||
currentRecord["providerID"] === target.providerID
|
||||
&& currentRecord["modelID"] === target.modelID
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the ultrawork model override config for the given agent and prompt text.
|
||||
* Returns null if no override should be applied.
|
||||
*/
|
||||
export function resolveUltraworkOverride(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
inputAgentName: string | undefined,
|
||||
@@ -81,7 +76,9 @@ export function resolveUltraworkOverride(
|
||||
if (!ultraworkConfig?.model && !ultraworkConfig?.variant) return null
|
||||
|
||||
if (!ultraworkConfig.model) {
|
||||
return { variant: ultraworkConfig.variant }
|
||||
return {
|
||||
variant: ultraworkConfig.variant,
|
||||
}
|
||||
}
|
||||
|
||||
const modelParts = ultraworkConfig.model.split("/")
|
||||
@@ -94,20 +91,37 @@ export function resolveUltraworkOverride(
|
||||
}
|
||||
}
|
||||
|
||||
function applyResolvedUltraworkOverride(args: {
|
||||
override: UltraworkOverrideResult
|
||||
validatedVariant: string | undefined
|
||||
output: { message: Record<string, unknown> }
|
||||
inputAgentName: string | undefined
|
||||
tui: unknown
|
||||
}): void {
|
||||
const { override, validatedVariant, output, inputAgentName, tui } = args
|
||||
if (validatedVariant) {
|
||||
output.message["variant"] = validatedVariant
|
||||
output.message["thinking"] = validatedVariant
|
||||
/**
|
||||
* Applies ultrawork model override using a deferred DB update strategy.
|
||||
*
|
||||
* Instead of directly mutating output.message.model (which would cause the TUI
|
||||
* bottom bar to show the override model), this schedules a queueMicrotask that
|
||||
* updates the message model directly in SQLite AFTER Session.updateMessage()
|
||||
* saves the original model, but BEFORE loop() reads it for the API call.
|
||||
*
|
||||
* Result: API call uses opus, TUI bottom bar stays on sonnet.
|
||||
*/
|
||||
export function applyUltraworkModelOverrideOnMessage(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
inputAgentName: string | undefined,
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
},
|
||||
tui: unknown,
|
||||
sessionID?: string,
|
||||
): void {
|
||||
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
||||
if (!override) return
|
||||
|
||||
if (override.variant) {
|
||||
output.message["variant"] = override.variant
|
||||
output.message["thinking"] = override.variant
|
||||
}
|
||||
|
||||
if (!override.providerID || !override.modelID) return
|
||||
if (!override.providerID || !override.modelID) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetModel = { providerID: override.providerID, modelID: override.modelID }
|
||||
if (isSameModel(output.message.model, targetModel)) {
|
||||
@@ -120,6 +134,7 @@ function applyResolvedUltraworkOverride(args: {
|
||||
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
|
||||
output.message.model = targetModel
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
const fromModel = (output.message.model as { modelID?: string } | undefined)?.modelID ?? "unknown"
|
||||
@@ -128,7 +143,11 @@ function applyResolvedUltraworkOverride(args: {
|
||||
(typeof output.message["agent"] === "string" ? (output.message["agent"] as string) : "unknown"),
|
||||
)
|
||||
|
||||
scheduleDeferredModelOverride(messageId, targetModel, validatedVariant)
|
||||
scheduleDeferredModelOverride(
|
||||
messageId,
|
||||
targetModel,
|
||||
override.variant,
|
||||
)
|
||||
|
||||
log(`[ultrawork-model-override] ${fromModel} -> ${override.modelID} (deferred DB)`, {
|
||||
agent: agentConfigKey,
|
||||
@@ -137,53 +156,6 @@ function applyResolvedUltraworkOverride(args: {
|
||||
showToast(
|
||||
tui,
|
||||
"Ultrawork Model Override",
|
||||
`${fromModel} → ${override.modelID}. Maximum precision engaged.`,
|
||||
`${fromModel} \u2192 ${override.modelID}. Maximum precision engaged.`,
|
||||
)
|
||||
}
|
||||
|
||||
export function applyUltraworkModelOverrideOnMessage(
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
inputAgentName: string | undefined,
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
},
|
||||
tui: unknown,
|
||||
sessionID?: string,
|
||||
client?: unknown,
|
||||
): void | Promise<void> {
|
||||
const override = resolveUltraworkOverride(pluginConfig, inputAgentName, output, sessionID)
|
||||
if (!override) return
|
||||
|
||||
const currentModel = getMessageModel(output.message.model)
|
||||
const variantTargetModel = override.providerID && override.modelID
|
||||
? { providerID: override.providerID, modelID: override.modelID }
|
||||
: currentModel
|
||||
|
||||
if (!client || typeof (client as { provider?: { list?: unknown } }).provider?.list !== "function") {
|
||||
applyResolvedUltraworkOverride({ override, validatedVariant: override.variant, output, inputAgentName, tui })
|
||||
return
|
||||
}
|
||||
|
||||
return resolveValidUltraworkVariant(client, variantTargetModel, override.variant)
|
||||
.then((validatedVariant) => {
|
||||
if (override.variant && !validatedVariant) {
|
||||
log("[ultrawork-model-override] Skip invalid ultrawork variant override", {
|
||||
variant: override.variant,
|
||||
providerID: variantTargetModel?.providerID,
|
||||
modelID: variantTargetModel?.modelID,
|
||||
})
|
||||
}
|
||||
|
||||
applyResolvedUltraworkOverride({ override, validatedVariant, output, inputAgentName, tui })
|
||||
})
|
||||
.catch((error) => {
|
||||
log("[ultrawork-model-override] Failed to validate ultrawork variant via SDK", {
|
||||
variant: override.variant,
|
||||
error: String(error),
|
||||
providerID: variantTargetModel?.providerID,
|
||||
modelID: variantTargetModel?.modelID,
|
||||
})
|
||||
applyResolvedUltraworkOverride({ override, validatedVariant: undefined, output, inputAgentName, tui })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import * as dbOverrideModule from "./ultrawork-db-model-override"
|
||||
import { applyUltraworkModelOverrideOnMessage } from "./ultrawork-model-override"
|
||||
import { resolveValidUltraworkVariant } from "./ultrawork-variant-availability"
|
||||
|
||||
describe("resolveValidUltraworkVariant", () => {
|
||||
function createClient(models: Record<string, Record<string, unknown>>) {
|
||||
return {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
all: Object.entries(models).map(([providerID, providerModels]) => ({
|
||||
id: providerID,
|
||||
models: providerModels,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("#given provider sdk metadata #when variant exists #then returns variant", async () => {
|
||||
// given
|
||||
const client = createClient({
|
||||
anthropic: {
|
||||
"claude-opus-4-6": {
|
||||
variants: {
|
||||
max: {},
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveValidUltraworkVariant(
|
||||
client,
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
"max",
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result).toBe("max")
|
||||
})
|
||||
|
||||
test("#given provider sdk metadata #when variant does not exist #then returns undefined", async () => {
|
||||
// given
|
||||
const client = createClient({
|
||||
anthropic: {
|
||||
"claude-opus-4-6": {
|
||||
variants: {
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveValidUltraworkVariant(
|
||||
client,
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
"max",
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyUltraworkModelOverrideOnMessage variant guard", () => {
|
||||
function createClient(models: Record<string, Record<string, unknown>>) {
|
||||
return {
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
all: Object.entries(models).map(([providerID, providerModels]) => ({
|
||||
id: providerID,
|
||||
models: providerModels,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("#given ultrawork variant missing from target model #when override applies #then skips forced variant change", async () => {
|
||||
// given
|
||||
const client = createClient({
|
||||
anthropic: {
|
||||
"claude-opus-4-6": {
|
||||
variants: {
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const dbOverrideSpy = spyOn(dbOverrideModule, "scheduleDeferredModelOverride").mockImplementation(() => {})
|
||||
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof applyUltraworkModelOverrideOnMessage>[0]
|
||||
|
||||
const output = {
|
||||
message: {
|
||||
id: "msg_123",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||
} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do something" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await applyUltraworkModelOverrideOnMessage(
|
||||
config,
|
||||
"sisyphus",
|
||||
output,
|
||||
{ showToast: async () => {} },
|
||||
undefined,
|
||||
client,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message["variant"]).toBeUndefined()
|
||||
expect(output.message["thinking"]).toBeUndefined()
|
||||
expect(dbOverrideSpy).toHaveBeenCalledWith(
|
||||
"msg_123",
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
undefined,
|
||||
)
|
||||
dbOverrideSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("#given variant only ultrawork config without valid current model variant #when override applies #then skips override entirely", async () => {
|
||||
// given
|
||||
const client = createClient({
|
||||
anthropic: {
|
||||
"claude-sonnet-4-6": {
|
||||
variants: {
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const dbOverrideSpy = spyOn(dbOverrideModule, "scheduleDeferredModelOverride").mockImplementation(() => {})
|
||||
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof applyUltraworkModelOverrideOnMessage>[0]
|
||||
|
||||
const output = {
|
||||
message: {
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" },
|
||||
} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do something" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await applyUltraworkModelOverrideOnMessage(
|
||||
config,
|
||||
"sisyphus",
|
||||
output,
|
||||
{ showToast: async () => {} },
|
||||
undefined,
|
||||
client,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output.message["variant"]).toBeUndefined()
|
||||
expect(output.message["thinking"]).toBeUndefined()
|
||||
expect(dbOverrideSpy).not.toHaveBeenCalled()
|
||||
expect(output.message.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
||||
dbOverrideSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { normalizeSDKResponse } from "../shared"
|
||||
|
||||
type ModelDescriptor = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
type ProviderListClient = {
|
||||
provider?: {
|
||||
list?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderModelMetadata = {
|
||||
variants?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ProviderListEntry = {
|
||||
id?: string
|
||||
models?: Record<string, ProviderModelMetadata>
|
||||
}
|
||||
|
||||
type ProviderListData = {
|
||||
all?: ProviderListEntry[]
|
||||
}
|
||||
|
||||
export async function resolveValidUltraworkVariant(
|
||||
client: unknown,
|
||||
model: ModelDescriptor | undefined,
|
||||
variant: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!model || !variant) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providerList = (client as ProviderListClient | null | undefined)?.provider?.list
|
||||
if (typeof providerList !== "function") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const response = await providerList()
|
||||
const data = normalizeSDKResponse<ProviderListData>(response, {})
|
||||
const providerEntry = data.all?.find((entry) => entry.id === model.providerID)
|
||||
const variants = providerEntry?.models?.[model.modelID]?.variants
|
||||
|
||||
if (!variants) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Object.hasOwn(variants, variant) ? variant : undefined
|
||||
}
|
||||
@@ -201,8 +201,8 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(hephaestus.requiresModel).toBeUndefined()
|
||||
})
|
||||
|
||||
test("all 11 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 11 agent names
|
||||
test("all 10 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 10 agent names
|
||||
const expectedAgents = [
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
@@ -214,14 +214,13 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas",
|
||||
"sisyphus-junior",
|
||||
]
|
||||
|
||||
// when - checking AGENT_MODEL_REQUIREMENTS
|
||||
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #then - all agents present with valid fallbackChain
|
||||
expect(definedAgents).toHaveLength(11)
|
||||
expect(definedAgents).toHaveLength(10)
|
||||
for (const agent of expectedAgents) {
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
|
||||
expect(requirement).toBeDefined()
|
||||
|
||||
@@ -170,19 +170,6 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4", variant: "medium" },
|
||||
],
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4", variant: "medium" },
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, expect, test } = require("bun:test")
|
||||
|
||||
import { extractRetryAttempt, extractRetryStatusModel, normalizeRetryStatusMessage } from "./retry-status-utils"
|
||||
|
||||
describe("retry-status-utils", () => {
|
||||
test("extracts retry attempt from explicit status attempt", () => {
|
||||
//#given
|
||||
const attempt = 6
|
||||
|
||||
//#when
|
||||
const result = extractRetryAttempt(attempt, "The usage limit has been reached [retrying in 27s attempt #6]")
|
||||
|
||||
//#then
|
||||
expect(result).toBe(6)
|
||||
})
|
||||
|
||||
test("extracts retry model from cooldown status text", () => {
|
||||
//#given
|
||||
const message = "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]"
|
||||
|
||||
//#when
|
||||
const result = extractRetryStatusModel(message)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("normalizes countdown jitter to a stable cooldown class", () => {
|
||||
//#given
|
||||
const firstMessage = "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]"
|
||||
const secondMessage = "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 55s attempt #1]"
|
||||
|
||||
//#when
|
||||
const firstResult = normalizeRetryStatusMessage(firstMessage)
|
||||
const secondResult = normalizeRetryStatusMessage(secondMessage)
|
||||
|
||||
//#then
|
||||
expect(firstResult).toBe("cooldown")
|
||||
expect(secondResult).toBe("cooldown")
|
||||
})
|
||||
})
|
||||
@@ -1,51 +1,19 @@
|
||||
const RETRY_COUNTDOWN_PATTERN = /\[\s*retrying\s+in[^\]]*\]/gi
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.toLowerCase().replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
export function extractRetryAttempt(attempt: number | undefined, message: string): number | "?" {
|
||||
if (typeof attempt === "number" && Number.isFinite(attempt)) {
|
||||
return attempt
|
||||
}
|
||||
|
||||
const parsedAttempt = message.match(/attempt\s*#\s*(\d+)/i)?.[1]
|
||||
return parsedAttempt ? Number.parseInt(parsedAttempt, 10) : "?"
|
||||
}
|
||||
|
||||
export function extractRetryStatusModel(message: string): string | undefined {
|
||||
return message.match(/model\s+([a-z0-9._/-]+)(?=\s+(?:are|is)\b)/i)?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
export function normalizeRetryStatusMessage(message: string): string {
|
||||
const normalizedMessage = collapseWhitespace(message.replace(RETRY_COUNTDOWN_PATTERN, " "))
|
||||
if (!normalizedMessage) {
|
||||
return "retry"
|
||||
}
|
||||
|
||||
if (/all\s+credentials\s+for\s+model|cool(?:ing)?\s+down|cooldown|exhausted\s+your\s+capacity/.test(normalizedMessage)) {
|
||||
return "cooldown"
|
||||
}
|
||||
|
||||
if (/too\s+many\s+requests/.test(normalizedMessage)) {
|
||||
return "too-many-requests"
|
||||
}
|
||||
|
||||
if (/quota\s+will\s+reset\s+after|quota\s*exceeded/.test(normalizedMessage)) {
|
||||
return "quota"
|
||||
}
|
||||
|
||||
if (/usage\s+limit\s+has\s+been\s+reached|limit\s+reached/.test(normalizedMessage)) {
|
||||
return "usage-limit"
|
||||
}
|
||||
|
||||
if (/rate\s+limit/.test(normalizedMessage)) {
|
||||
return "rate-limit"
|
||||
}
|
||||
|
||||
if (/service.?unavailable|temporarily.?unavailable|overloaded/.test(normalizedMessage)) {
|
||||
return "service-unavailable"
|
||||
}
|
||||
|
||||
return normalizedMessage
|
||||
return message
|
||||
.replace(/\[retrying in [^\]]*attempt\s*#\d+\]/gi, "[retrying]")
|
||||
.replace(/retrying in\s+[^(]*attempt\s*#\d+/gi, "retrying")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function extractRetryAttempt(statusAttempt: unknown, message: string): string {
|
||||
if (typeof statusAttempt === "number" && Number.isFinite(statusAttempt)) {
|
||||
return String(statusAttempt)
|
||||
}
|
||||
const attemptMatch = message.match(/attempt\s*#\s*(\d+)/i)
|
||||
if (attemptMatch?.[1]) {
|
||||
return attemptMatch[1]
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const REPO = "ast-grep/ast-grep"
|
||||
|
||||
// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
|
||||
// This is only used as fallback when @ast-grep/cli package.json cannot be read
|
||||
const DEFAULT_VERSION = "0.41.1"
|
||||
const DEFAULT_VERSION = "0.40.0"
|
||||
|
||||
function getAstGrepVersion(): string {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user