diff --git a/.agent/workflows/update_clawdbot.md b/.agent/workflows/update_clawdbot.md deleted file mode 100644 index 0543e7c2a680..000000000000 --- a/.agent/workflows/update_clawdbot.md +++ /dev/null @@ -1,380 +0,0 @@ ---- -description: Update OpenClaw from upstream when branch has diverged (ahead/behind) ---- - -# OpenClaw Upstream Sync Workflow - -Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind"). - -## Quick Reference - -```bash -# Check divergence status -git fetch upstream && git rev-list --left-right --count main...upstream/main - -# Full sync (rebase preferred) -git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh - -# Check for Swift 6.2 issues after sync -grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" -``` - ---- - -## Step 1: Assess Divergence - -```bash -git fetch upstream -git log --oneline --left-right main...upstream/main | head -20 -``` - -This shows: - -- `<` = your local commits (ahead) -- `>` = upstream commits you're missing (behind) - -**Decision point:** - -- Few local commits, many upstream → **Rebase** (cleaner history) -- Many local commits or shared branch → **Merge** (preserves history) - ---- - -## Step 2A: Rebase Strategy (Preferred) - -Replays your commits on top of upstream. Results in linear history. - -```bash -# Ensure working tree is clean -git status - -# Rebase onto upstream -git rebase upstream/main -``` - -### Handling Rebase Conflicts - -```bash -# When conflicts occur: -# 1. Fix conflicts in the listed files -# 2. Stage resolved files -git add - -# 3. Continue rebase -git rebase --continue - -# If a commit is no longer needed (already in upstream): -git rebase --skip - -# To abort and return to original state: -git rebase --abort -``` - -### Common Conflict Patterns - -| File | Resolution | -| ---------------- | ------------------------------------------------ | -| `package.json` | Take upstream deps, keep local scripts if needed | -| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` | -| `*.patch` files | Usually take upstream version | -| Source files | Merge logic carefully, prefer upstream structure | - ---- - -## Step 2B: Merge Strategy (Alternative) - -Preserves all history with a merge commit. - -```bash -git merge upstream/main --no-edit -``` - -Resolve conflicts same as rebase, then: - -```bash -git add -git commit -``` - ---- - -## Step 3: Rebuild Everything - -After sync completes: - -```bash -# Install dependencies (regenerates lock if needed) -pnpm install - -# Build TypeScript -pnpm build - -# Build UI assets -pnpm ui:build - -# Run diagnostics -pnpm clawdbot doctor -``` - ---- - -## Step 4: Rebuild macOS App - -```bash -# Full rebuild, sign, and launch -./scripts/restart-mac.sh - -# Or just package without restart -pnpm mac:package -``` - -### Install to /Applications - -```bash -# Kill running app -pkill -x "OpenClaw" || true - -# Move old version -mv /Applications/OpenClaw.app /tmp/OpenClaw-backup.app - -# Install new build -cp -R dist/OpenClaw.app /Applications/ - -# Launch -open /Applications/OpenClaw.app -``` - ---- - -## Step 4A: Verify macOS App & Agent - -After rebuilding the macOS app, always verify it works correctly: - -```bash -# Check gateway health -pnpm clawdbot health - -# Verify no zombie processes -ps aux | grep -E "(clawdbot|gateway)" | grep -v grep - -# Test agent functionality by sending a verification message -pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID - -# Confirm the message was received on Telegram -# (Check your Telegram chat with the bot) -``` - -**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing. - ---- - -## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync) - -Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging: - -### Analyze-Mode Investigation - -```bash -# Gather context with parallel agents -morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis" -morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis" -``` - -### Common Swift 6.2 Fixes - -**FileManager.default Deprecation:** - -```bash -# Search for deprecated usage -grep -r "FileManager\.default" src/ apps/ --include="*.swift" - -# Replace with proper initialization -# OLD: FileManager.default -# NEW: FileManager() -``` - -**Thread.isMainThread Deprecation:** - -```bash -# Search for deprecated usage -grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift" - -# Replace with modern concurrency check -# OLD: Thread.isMainThread -# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... } -``` - -### Peekaboo Submodule Fixes - -```bash -# Check Peekaboo for concurrency issues -cd src/canvas-host/a2ui -grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift" - -# Fix and rebuild submodule -cd /Volumes/Main SSD/Developer/clawdis -pnpm canvas:a2ui:bundle -``` - -### macOS App Concurrency Fixes - -```bash -# Check macOS app for issues -grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift" - -# Clean and rebuild after fixes -cd apps/macos && rm -rf .build .swiftpm -./scripts/restart-mac.sh -``` - -### Model Configuration Updates - -If upstream introduced new model configurations: - -```bash -# Check for OpenRouter API key requirements -grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js" - -# Update openclaw.json with fallback chains -# Add model fallback configurations as needed -``` - ---- - -## Step 6: Verify & Push - -```bash -# Verify everything works -pnpm clawdbot health -pnpm test - -# Push (force required after rebase) -git push origin main --force-with-lease - -# Or regular push after merge -git push origin main -``` - ---- - -## Troubleshooting - -### Build Fails After Sync - -```bash -# Clean and rebuild -rm -rf node_modules dist -pnpm install -pnpm build -``` - -### Type Errors (Bun/Node Incompatibility) - -Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`. - -### macOS App Crashes on Launch - -Usually resource bundle mismatch. Full rebuild required: - -```bash -cd apps/macos && rm -rf .build .swiftpm -./scripts/restart-mac.sh -``` - -### Patch Failures - -```bash -# Check patch status -pnpm install 2>&1 | grep -i patch - -# If patches fail, they may need updating for new dep versions -# Check patches/ directory against package.json patchedDependencies -``` - -### Swift 6.2 / macOS 26 SDK Build Failures - -**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread` - -**Search-Mode Investigation:** - -```bash -# Exhaustive search for deprecated APIs -morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis" -``` - -**Quick Fix Commands:** - -```bash -# Find all affected files -find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \; - -# Replace FileManager.default with FileManager() -find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \; - -# For Thread.isMainThread, need manual review of each usage -grep -rn "Thread\.isMainThread" --include="*.swift" . -``` - -**Rebuild After Fixes:** - -```bash -# Clean all build artifacts -rm -rf apps/macos/.build apps/macos/.swiftpm -rm -rf src/canvas-host/a2ui/.build - -# Rebuild Peekaboo bundle -pnpm canvas:a2ui:bundle - -# Full macOS rebuild -./scripts/restart-mac.sh -``` - ---- - -## Automation Script - -Save as `scripts/sync-upstream.sh`: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -echo "==> Fetching upstream..." -git fetch upstream - -echo "==> Current divergence:" -git rev-list --left-right --count main...upstream/main - -echo "==> Rebasing onto upstream/main..." -git rebase upstream/main - -echo "==> Installing dependencies..." -pnpm install - -echo "==> Building..." -pnpm build -pnpm ui:build - -echo "==> Running doctor..." -pnpm clawdbot doctor - -echo "==> Rebuilding macOS app..." -./scripts/restart-mac.sh - -echo "==> Verifying gateway health..." -pnpm clawdbot health - -echo "==> Checking for Swift 6.2 compatibility issues..." -if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then - echo "⚠️ Found potential Swift 6.2 deprecated API usage" - echo " Run manual fixes or use analyze-mode investigation" -else - echo "✅ No obvious Swift deprecation issues found" -fi - -echo "==> Testing agent functionality..." -# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID -pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message" - -echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready." -``` diff --git a/.agents/skills/parallels-discord-roundtrip/SKILL.md b/.agents/skills/parallels-discord-roundtrip/SKILL.md deleted file mode 100644 index cbfffc21446c..000000000000 --- a/.agents/skills/parallels-discord-roundtrip/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: parallels-discord-roundtrip -description: Run the macOS Parallels smoke harness with Discord end-to-end roundtrip verification, including guest send, host verification, host reply, and guest readback. ---- - -# Parallels Discord Roundtrip - -Use when macOS Parallels smoke must prove Discord two-way delivery end to end. - -## Goal - -Cover: - -- install on fresh macOS snapshot -- onboard + gateway health -- guest `message send` to Discord -- host sees that message on Discord -- host posts a new Discord message -- guest `message read` sees that new message - -## Inputs - -- host env var with Discord bot token -- Discord guild ID -- Discord channel ID -- `OPENAI_API_KEY` - -## Preferred run - -```bash -export OPENCLAW_PARALLELS_DISCORD_TOKEN="$( - ssh peters-mac-studio-1 'jq -r ".channels.discord.token" ~/.openclaw/openclaw.json' | tr -d '\n' -)" - -pnpm test:parallels:macos \ - --discord-token-env OPENCLAW_PARALLELS_DISCORD_TOKEN \ - --discord-guild-id 1456350064065904867 \ - --discord-channel-id 1456744319972282449 \ - --json -``` - -## Notes - -- Snapshot target: closest to `macOS 26.3.1 fresh`. -- Snapshot resolver now prefers matching `*-poweroff*` clones when the base hint also matches. That lets the harness reuse disk-only recovery snapshots without passing a longer hint. -- If Windows/Linux snapshot restore logs show `PET_QUESTION_SNAPSHOT_STATE_INCOMPATIBLE_CPU`, drop the suspended state once, create a `*-poweroff*` replacement snapshot, and rerun. The smoke scripts now auto-start restored power-off snapshots. -- Harness configures Discord inside the guest; no checked-in token/config. -- Use the `openclaw` wrapper for guest `message send/read`; `node openclaw.mjs message ...` does not expose the lazy message subcommands the same way. -- Write `channels.discord.guilds` in one JSON object (`--strict-json`), not dotted `config set channels.discord.guilds....` paths; numeric snowflakes get treated like array indexes. -- Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase. -- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load. -- Harness cleanup deletes the temporary Discord smoke messages at exit. -- Per-phase logs: `/tmp/openclaw-parallels-smoke.*` -- Machine summary: pass `--json` -- If roundtrip flakes, inspect `fresh.discord-roundtrip.log` and `discord-last-readback.json` in the run dir first. - -## Pass criteria - -- fresh lane or upgrade lane requested passes -- summary reports `discord=pass` for that lane -- guest outbound nonce appears in channel history -- host inbound nonce appears in `openclaw message read` output diff --git a/extensions/chutes/chutes.test.ts b/extensions/chutes/chutes.test.ts new file mode 100644 index 000000000000..819ad50336a5 --- /dev/null +++ b/extensions/chutes/chutes.test.ts @@ -0,0 +1,173 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { CHUTES_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; +import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth"; +import { CHUTES_BASE_URL } from "openclaw/plugin-sdk/provider-models"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + applyChutesConfig, + applyChutesProviderConfig, + CHUTES_DEFAULT_MODEL_REF, +} from "./onboard.js"; +import { buildChutesProvider } from "./provider-catalog.js"; + +describe("chutes extension", () => { + describe("buildChutesProvider", () => { + it("returns a provider config with the Chutes base URL and openai-completions API", async () => { + const provider = await buildChutesProvider(); + expect(provider.baseUrl).toBe("https://llm.chutes.ai/v1"); + expect(provider.api).toBe("openai-completions"); + expect(Array.isArray(provider.models)).toBe(true); + expect(provider.models.length).toBeGreaterThan(0); + }); + + it("returns non-empty models from static catalog when no token provided", async () => { + const provider = await buildChutesProvider(); + const firstModel = provider.models[0]; + expect(typeof firstModel?.id).toBe("string"); + expect(firstModel?.id.length).toBeGreaterThan(0); + }); + }); + + describe("applyChutesProviderConfig", () => { + it("adds the chutes provider to the config", () => { + const result = applyChutesProviderConfig({}); + expect(result.models?.providers?.["chutes"]).toBeDefined(); + expect(result.models?.providers?.["chutes"]?.baseUrl).toBe("https://llm.chutes.ai/v1"); + }); + + it("registers model aliases", () => { + const result = applyChutesProviderConfig({}); + const models = result.agents?.defaults?.models ?? {}; + expect(models["chutes-fast"]).toBeDefined(); + expect(models["chutes-pro"]).toBeDefined(); + expect(models["chutes-vision"]).toBeDefined(); + }); + }); + + describe("applyChutesConfig", () => { + it("sets the primary model to the Chutes default", () => { + const result = applyChutesConfig({}); + const model = result.agents?.defaults?.model; + const primary = typeof model === "object" ? model?.primary : model; + expect(primary).toBe(CHUTES_DEFAULT_MODEL_REF); + }); + + it("includes fallback models", () => { + const result = applyChutesConfig({}); + const model = result.agents?.defaults?.model; + const fallbacks = typeof model === "object" ? (model?.fallbacks ?? []) : []; + expect(fallbacks.length).toBeGreaterThan(0); + }); + + it("sets an image model", () => { + const result = applyChutesConfig({}); + const imageModel = result.agents?.defaults?.imageModel; + const primary = typeof imageModel === "object" ? imageModel?.primary : imageModel; + expect(primary).toBeDefined(); + }); + }); + + describe("CHUTES_DEFAULT_MODEL_REF", () => { + it("starts with chutes/ prefix", () => { + expect(CHUTES_DEFAULT_MODEL_REF).toMatch(/^chutes\//); + }); + }); + + describe("OAuth profile auth mode", () => { + let tempDir: string | null = null; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chutes-test-")); + }); + + afterEach(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it("resolvesCHUTES_OAUTH_MARKER for oauth-backed profiles", async () => { + const agentDir = tempDir!; + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify({ + version: 1, + profiles: { + "chutes:default": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }), + "utf8", + ); + + const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const oauthProfileId = Object.keys(authStore.profiles).find( + (id) => + authStore.profiles[id]?.provider === "chutes" && authStore.profiles[id]?.type === "oauth", + ); + expect(oauthProfileId).toBeDefined(); + + // Verify CHUTES_OAUTH_MARKER is the expected sentinel value + expect(CHUTES_OAUTH_MARKER).toBe("chutes-oauth"); + + // Verify the provider constant is consistent + expect(CHUTES_BASE_URL).toBe("https://llm.chutes.ai/v1"); + }); + + it("forwards oauth access token to discovery", async () => { + const agentDir = tempDir!; + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify({ + version: 1, + profiles: { + "chutes:default": { + type: "oauth", + provider: "chutes", + access: "my-chutes-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }), + "utf8", + ); + + const originalVitest = process.env.VITEST; + const originalNodeEnv = process.env.NODE_ENV; + const originalFetch = globalThis.fetch; + delete process.env.VITEST; + delete process.env.NODE_ENV; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "chutes/private-model" }] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + // Test that buildChutesProvider uses the access token for discovery + await buildChutesProvider("my-chutes-access-token"); + + const chutesCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("chutes.ai"), + ); + expect(chutesCalls.length).toBeGreaterThan(0); + const request = chutesCalls[0]?.[1] as { headers?: Record } | undefined; + expect(request?.headers?.Authorization).toBe("Bearer my-chutes-access-token"); + } finally { + process.env.VITEST = originalVitest; + process.env.NODE_ENV = originalNodeEnv; + globalThis.fetch = originalFetch; + } + }); + }); +}); diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts new file mode 100644 index 000000000000..e4c2cddf0082 --- /dev/null +++ b/extensions/chutes/index.ts @@ -0,0 +1,96 @@ +import { CHUTES_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime"; +import { definePluginEntry, type ProviderCatalogContext } from "openclaw/plugin-sdk/core"; +import { + createProviderApiKeyAuthMethod, + ensureAuthProfileStore, + listProfilesForProvider, +} from "openclaw/plugin-sdk/provider-auth"; +import { CHUTES_DEFAULT_MODEL_REF, applyChutesApiKeyConfig } from "./onboard.js"; +import { buildChutesProvider } from "./provider-catalog.js"; + +const PROVIDER_ID = "chutes"; + +/** + * Resolve the Chutes implicit provider. + * - If an API key is available (env var or api_key profile), use it directly. + * - If an OAuth profile exists and no API key is present, use CHUTES_OAUTH_MARKER + * so the gateway injects the stored access token at request time. + */ +async function resolveCatalog(ctx: ProviderCatalogContext) { + const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID); + if (apiKey) { + return { + provider: { + ...(await buildChutesProvider(discoveryApiKey)), + apiKey, + }, + }; + } + + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const oauthProfileId = listProfilesForProvider(authStore, PROVIDER_ID).find( + (id) => authStore.profiles[id]?.type === "oauth", + ); + if (!oauthProfileId) { + return null; + } + + // Pass the stored access token for authenticated model discovery. + // discoverChutesModels retries without auth on 401, so an expired token degrades gracefully. + const oauthCred = authStore.profiles[oauthProfileId]; + const accessToken = oauthCred?.type === "oauth" ? oauthCred.access : undefined; + + return { + provider: { + ...(await buildChutesProvider(accessToken)), + apiKey: CHUTES_OAUTH_MARKER, + }, + }; +} + +export default definePluginEntry({ + id: PROVIDER_ID, + name: "Chutes Provider", + description: "Bundled Chutes.ai provider plugin", + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Chutes", + docsPath: "/providers/chutes", + envVars: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Chutes API key", + hint: "Open-source models including Llama, DeepSeek, and more", + optionKey: "chutesApiKey", + flagName: "--chutes-api-key", + envVar: "CHUTES_API_KEY", + promptMessage: "Enter Chutes API key", + noteTitle: "Chutes", + noteMessage: [ + "Chutes provides access to leading open-source models including Llama, DeepSeek, and more.", + "Get your API key at: https://chutes.ai/settings/api-keys", + ].join("\n"), + defaultModel: CHUTES_DEFAULT_MODEL_REF, + expectedProviders: ["chutes"], + applyConfig: (cfg) => applyChutesApiKeyConfig(cfg), + wizard: { + choiceId: "chutes-api-key", + choiceLabel: "Chutes API key", + groupId: "chutes", + groupLabel: "Chutes", + groupHint: "OAuth + API key", + }, + }), + ], + catalog: { + order: "profile", + run: resolveCatalog, + }, + }); + }, +}); diff --git a/extensions/chutes/onboard.ts b/extensions/chutes/onboard.ts new file mode 100644 index 000000000000..f51914c3ca83 --- /dev/null +++ b/extensions/chutes/onboard.ts @@ -0,0 +1,67 @@ +import { + CHUTES_BASE_URL, + CHUTES_DEFAULT_MODEL_REF, + CHUTES_MODEL_CATALOG, + buildChutesModelDefinition, +} from "openclaw/plugin-sdk/provider-models"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; + +export { CHUTES_DEFAULT_MODEL_REF }; + +/** + * Apply Chutes provider configuration without changing the default model. + * Registers all catalog models and sets provider aliases (chutes-fast, etc.). + */ +export function applyChutesProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + for (const m of CHUTES_MODEL_CATALOG) { + models[`chutes/${m.id}`] = { + ...models[`chutes/${m.id}`], + }; + } + + models["chutes-fast"] = { alias: "chutes/zai-org/GLM-4.7-FP8" }; + models["chutes-vision"] = { alias: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506" }; + models["chutes-pro"] = { alias: "chutes/deepseek-ai/DeepSeek-V3.2-TEE" }; + + const chutesModels = CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition); + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "chutes", + api: "openai-completions", + baseUrl: CHUTES_BASE_URL, + catalogModels: chutesModels, + }); +} + +/** + * Apply Chutes provider configuration AND set Chutes as the default model. + */ +export function applyChutesConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyChutesProviderConfig(cfg); + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + primary: CHUTES_DEFAULT_MODEL_REF, + fallbacks: ["chutes/deepseek-ai/DeepSeek-V3.2-TEE", "chutes/Qwen/Qwen3-32B"], + }, + imageModel: { + primary: "chutes/chutesai/Mistral-Small-3.2-24B-Instruct-2506", + fallbacks: ["chutes/chutesai/Mistral-Small-3.1-24B-Instruct-2503"], + }, + }, + }, + }; +} + +export function applyChutesApiKeyConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyChutesProviderConfig(cfg), CHUTES_DEFAULT_MODEL_REF); +} diff --git a/extensions/chutes/openclaw.plugin.json b/extensions/chutes/openclaw.plugin.json new file mode 100644 index 000000000000..b15b62fc552f --- /dev/null +++ b/extensions/chutes/openclaw.plugin.json @@ -0,0 +1,28 @@ +{ + "id": "chutes", + "providers": ["chutes"], + "providerAuthEnvVars": { + "chutes": ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"] + }, + "providerAuthChoices": [ + { + "provider": "chutes", + "method": "api-key", + "choiceId": "chutes-api-key", + "choiceLabel": "Chutes API key", + "choiceHint": "Open-source models including Llama, DeepSeek, and more", + "groupId": "chutes", + "groupLabel": "Chutes", + "groupHint": "OAuth + API key", + "optionKey": "chutesApiKey", + "cliFlag": "--chutes-api-key", + "cliOption": "--chutes-api-key ", + "cliDescription": "Chutes API key" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/chutes/package.json b/extensions/chutes/package.json new file mode 100644 index 000000000000..be860172a277 --- /dev/null +++ b/extensions/chutes/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/chutes-provider", + "version": "2026.3.17", + "private": true, + "description": "OpenClaw Chutes.ai provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/chutes/provider-catalog.ts b/extensions/chutes/provider-catalog.ts new file mode 100644 index 000000000000..1467f405dde9 --- /dev/null +++ b/extensions/chutes/provider-catalog.ts @@ -0,0 +1,21 @@ +import { + CHUTES_BASE_URL, + CHUTES_MODEL_CATALOG, + buildChutesModelDefinition, + discoverChutesModels, + type ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-models"; + +/** + * Build the Chutes provider with dynamic model discovery. + * Falls back to the static catalog on failure. + * Accepts an optional access token (API key or OAuth access token) for authenticated discovery. + */ +export async function buildChutesProvider(accessToken?: string): Promise { + const models = await discoverChutesModels(accessToken); + return { + baseUrl: CHUTES_BASE_URL, + api: "openai-completions", + models: models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition), + }; +} diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 4594f09fd592..ea7dbcb51ec0 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -77,12 +77,9 @@ vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient, })); -vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({ +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params), ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params), -})); - -vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ getSessionBindingService: () => ({ resolveByConversation: mockResolveBoundConversation, touch: mockTouchBinding, diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index ccaf6ea6d0d8..6efda0cbb4e6 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js"; +const clientCtorMock = vi.hoisted(() => vi.fn()); const wsClientCtorMock = vi.hoisted(() => vi.fn(function wsClientCtor() { return { connected: true }; @@ -22,22 +23,6 @@ const mockBaseHttpInstance = vi.hoisted(() => ({ head: vi.fn().mockResolvedValue({}), options: vi.fn().mockResolvedValue({}), })); - -vi.mock("@larksuiteoapi/node-sdk", () => ({ - AppType: { SelfBuild: "self" }, - Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" }, - LoggerLevel: { info: "info" }, - Client: vi.fn(), - WSClient: wsClientCtorMock, - EventDispatcher: vi.fn(), - defaultHttpInstance: mockBaseHttpInstance, -})); - -vi.mock("https-proxy-agent", () => ({ - HttpsProxyAgent: httpsProxyAgentCtorMock, -})); - -import { Client as LarkClient } from "@larksuiteoapi/node-sdk"; import { createFeishuClient, createFeishuWSClient, @@ -45,6 +30,7 @@ import { FEISHU_HTTP_TIMEOUT_MS, FEISHU_HTTP_TIMEOUT_MAX_MS, FEISHU_HTTP_TIMEOUT_ENV_VAR, + setFeishuClientRuntimeForTest, } from "./client.js"; const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const; @@ -78,6 +64,21 @@ beforeEach(() => { delete process.env[key]; } vi.clearAllMocks(); + setFeishuClientRuntimeForTest({ + sdk: { + AppType: { SelfBuild: "self" } as never, + Domain: { + Feishu: "https://open.feishu.cn", + Lark: "https://open.larksuite.com", + } as never, + LoggerLevel: { info: "info" } as never, + Client: clientCtorMock as never, + WSClient: wsClientCtorMock as never, + EventDispatcher: vi.fn() as never, + defaultHttpInstance: mockBaseHttpInstance as never, + }, + HttpsProxyAgent: httpsProxyAgentCtorMock as never, + }); }); afterEach(() => { @@ -94,6 +95,7 @@ afterEach(() => { } else { process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv; } + setFeishuClientRuntimeForTest(); }); describe("createFeishuClient HTTP timeout", () => { @@ -102,7 +104,7 @@ describe("createFeishuClient HTTP timeout", () => { }); const getLastClientHttpInstance = () => { - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; const lastCall = calls[calls.length - 1]?.[0] as | { httpInstance?: { get: (...args: unknown[]) => Promise } } | undefined; @@ -122,7 +124,7 @@ describe("createFeishuClient HTTP timeout", () => { it("passes a custom httpInstance with default timeout to Lark.Client", () => { createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown }; expect(lastCall.httpInstance).toBeDefined(); }); @@ -130,7 +132,7 @@ describe("createFeishuClient HTTP timeout", () => { it("injects default timeout into HTTP request options", async () => { createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; const lastCall = calls[calls.length - 1][0] as { httpInstance: { post: (...args: unknown[]) => Promise }; }; @@ -152,7 +154,7 @@ describe("createFeishuClient HTTP timeout", () => { it("allows explicit timeout override per-request", async () => { createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; const lastCall = calls[calls.length - 1][0] as { httpInstance: { get: (...args: unknown[]) => Promise }; }; @@ -241,7 +243,7 @@ describe("createFeishuClient HTTP timeout", () => { config: { httpTimeoutMs: 45_000 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; + const calls = clientCtorMock.mock.calls; expect(calls.length).toBe(2); const lastCall = calls[calls.length - 1][0] as { diff --git a/extensions/feishu/src/client.ts b/extensions/feishu/src/client.ts index d9fdde7f0595..c4498dcffc3c 100644 --- a/extensions/feishu/src/client.ts +++ b/extensions/feishu/src/client.ts @@ -2,6 +2,30 @@ import * as Lark from "@larksuiteoapi/node-sdk"; import { HttpsProxyAgent } from "https-proxy-agent"; import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js"; +type FeishuClientSdk = Pick< + typeof Lark, + | "AppType" + | "Client" + | "defaultHttpInstance" + | "Domain" + | "EventDispatcher" + | "LoggerLevel" + | "WSClient" +>; + +const defaultFeishuClientSdk: FeishuClientSdk = { + AppType: Lark.AppType, + Client: Lark.Client, + defaultHttpInstance: Lark.defaultHttpInstance, + Domain: Lark.Domain, + EventDispatcher: Lark.EventDispatcher, + LoggerLevel: Lark.LoggerLevel, + WSClient: Lark.WSClient, +}; + +let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk; +let httpsProxyAgentCtor: typeof HttpsProxyAgent = HttpsProxyAgent; + /** Default HTTP timeout for Feishu API requests (30 seconds). */ export const FEISHU_HTTP_TIMEOUT_MS = 30_000; export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000; @@ -14,7 +38,7 @@ function getWsProxyAgent(): HttpsProxyAgent | undefined { process.env.http_proxy || process.env.HTTP_PROXY; if (!proxyUrl) return undefined; - return new HttpsProxyAgent(proxyUrl); + return new httpsProxyAgentCtor(proxyUrl); } // Multi-account client cache @@ -28,10 +52,10 @@ const clientCache = new Map< function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string { if (domain === "lark") { - return Lark.Domain.Lark; + return feishuClientSdk.Domain.Lark; } if (domain === "feishu" || !domain) { - return Lark.Domain.Feishu; + return feishuClientSdk.Domain.Feishu; } return domain.replace(/\/+$/, ""); // Custom URL for private deployment } @@ -42,7 +66,8 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string { * (e.g. when the Feishu API is slow, causing per-chat queue deadlocks). */ function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance { - const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance; + const base: Lark.HttpInstance = + feishuClientSdk.defaultHttpInstance as unknown as Lark.HttpInstance; function injectTimeout(opts?: Lark.HttpRequestOptions): Lark.HttpRequestOptions { return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions; @@ -129,10 +154,10 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client } // Create new client with timeout-aware HTTP instance - const client = new Lark.Client({ + const client = new feishuClientSdk.Client({ appId, appSecret, - appType: Lark.AppType.SelfBuild, + appType: feishuClientSdk.AppType.SelfBuild, domain: resolveDomain(domain), httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs), }); @@ -158,11 +183,11 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli } const agent = getWsProxyAgent(); - return new Lark.WSClient({ + return new feishuClientSdk.WSClient({ appId, appSecret, domain: resolveDomain(domain), - loggerLevel: Lark.LoggerLevel.info, + loggerLevel: feishuClientSdk.LoggerLevel.info, ...(agent ? { agent } : {}), }); } @@ -171,7 +196,7 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli * Create an event dispatcher for an account. */ export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher { - return new Lark.EventDispatcher({ + return new feishuClientSdk.EventDispatcher({ encryptKey: account.encryptKey, verificationToken: account.verificationToken, }); @@ -194,3 +219,13 @@ export function clearClientCache(accountId?: string): void { clientCache.clear(); } } + +export function setFeishuClientRuntimeForTest(overrides?: { + sdk?: Partial; + HttpsProxyAgent?: typeof HttpsProxyAgent; +}): void { + feishuClientSdk = overrides?.sdk + ? { ...defaultFeishuClientSdk, ...overrides.sdk } + : defaultFeishuClientSdk; + httpsProxyAgentCtor = overrides?.HttpsProxyAgent ?? HttpsProxyAgent; +} diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 67ea2c1b77fb..59b5dc3da707 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -256,6 +256,13 @@ describe("sendMediaFeishu msg_type routing", () => { }); expectMediaTimeoutClientConfigured(); + expect(imageCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + image_type: "message", + }), + }), + ); expect(messageCreateMock).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ msg_type: "image" }), diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index bfc270a44594..ec1ebdc5b775 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -1,11 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const createFeishuClientMock = vi.hoisted(() => vi.fn()); - -vi.mock("./client.js", () => ({ - createFeishuClient: createFeishuClientMock, +const clientCtorMock = vi.hoisted(() => vi.fn()); +const mockBaseHttpInstance = vi.hoisted(() => ({ + request: vi.fn().mockResolvedValue({}), + get: vi.fn().mockResolvedValue({}), + post: vi.fn().mockResolvedValue({}), + put: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + head: vi.fn().mockResolvedValue({}), + options: vi.fn().mockResolvedValue({}), })); +import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js"; import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js"; const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret @@ -28,9 +35,15 @@ function makeRequestFn(response: Record) { return vi.fn().mockResolvedValue(response); } +function installClientCtor(requestFn: unknown) { + clientCtorMock.mockImplementation(function MockFeishuClient(this: { request: unknown }) { + this.request = requestFn; + } as never); +} + function setupClient(response: Record) { const requestFn = makeRequestFn(response); - createFeishuClientMock.mockReturnValue({ request: requestFn }); + installClientCtor(requestFn); return requestFn; } @@ -60,7 +73,7 @@ async function expectErrorResultCached(params: { expectedError: string; ttlMs: number; }) { - createFeishuClientMock.mockReturnValue({ request: params.requestFn }); + installClientCtor(params.requestFn); const first = await probeFeishu(DEFAULT_CREDS); const second = await probeFeishu(DEFAULT_CREDS); @@ -95,11 +108,25 @@ async function readSequentialDefaultProbePair() { describe("probeFeishu", () => { beforeEach(() => { clearProbeCache(); - vi.restoreAllMocks(); + clearClientCache(); + vi.clearAllMocks(); + setFeishuClientRuntimeForTest({ + sdk: { + AppType: { SelfBuild: "self" } as never, + Domain: { + Feishu: "https://open.feishu.cn", + Lark: "https://open.larksuite.com", + } as never, + Client: clientCtorMock as never, + defaultHttpInstance: mockBaseHttpInstance as never, + }, + }); }); afterEach(() => { clearProbeCache(); + clearClientCache(); + setFeishuClientRuntimeForTest(); }); it("returns error when credentials are missing", async () => { @@ -141,7 +168,7 @@ describe("probeFeishu", () => { it("returns timeout error when request exceeds timeout", async () => { await withFakeTimers(async () => { const requestFn = vi.fn().mockImplementation(() => new Promise(() => {})); - createFeishuClientMock.mockReturnValue({ request: requestFn }); + installClientCtor(requestFn); const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 }); await vi.advanceTimersByTimeAsync(1_000); @@ -152,7 +179,6 @@ describe("probeFeishu", () => { }); it("returns aborted when abort signal is already aborted", async () => { - createFeishuClientMock.mockClear(); const abortController = new AbortController(); abortController.abort(); @@ -162,7 +188,7 @@ describe("probeFeishu", () => { ); expect(result).toMatchObject({ ok: false, error: "probe aborted" }); - expect(createFeishuClientMock).not.toHaveBeenCalled(); + expect(clientCtorMock).not.toHaveBeenCalled(); }); it("returns cached result on subsequent calls within TTL", async () => { const requestFn = setupSuccessClient(); diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts index 1c1e88db0422..670ae4de943c 100644 --- a/extensions/google/oauth.credentials.ts +++ b/extensions/google/oauth.credentials.ts @@ -1,7 +1,27 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import type { Dirent } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js"; +type CredentialFs = { + existsSync: (path: Parameters[0]) => ReturnType; + readFileSync: (path: Parameters[0], encoding: "utf8") => string; + realpathSync: (path: Parameters[0]) => string; + readdirSync: ( + path: Parameters[0], + options: { withFileTypes: true }, + ) => Dirent[]; +}; + +const defaultFs: CredentialFs = { + existsSync, + readFileSync, + realpathSync, + readdirSync, +}; + +let credentialFs: CredentialFs = defaultFs; + function resolveEnv(keys: string[]): string | undefined { for (const key of keys) { const value = process.env[key]?.trim(); @@ -18,6 +38,10 @@ export function clearCredentialsCache(): void { cachedGeminiCliCredentials = null; } +export function setOAuthCredentialsFsForTest(overrides?: Partial): void { + credentialFs = overrides ? { ...defaultFs, ...overrides } : defaultFs; +} + export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { if (cachedGeminiCliCredentials) { return cachedGeminiCliCredentials; @@ -29,7 +53,7 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: return null; } - const resolvedPath = realpathSync(geminiPath); + const resolvedPath = credentialFs.realpathSync(geminiPath); const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); let content: string | null = null; @@ -55,10 +79,9 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: "oauth2.js", ), ]; - for (const path of searchPaths) { - if (existsSync(path)) { - content = readFileSync(path, "utf8"); + if (credentialFs.existsSync(path)) { + content = credentialFs.readFileSync(path, "utf8"); break; } } @@ -67,7 +90,7 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret: } const found = findFile(geminiCliDir, "oauth2.js", 10); if (found) { - content = readFileSync(found, "utf8"); + content = credentialFs.readFileSync(found, "utf8"); break; } } @@ -116,7 +139,7 @@ function findInPath(name: string): string | null { for (const dir of (process.env.PATH ?? "").split(delimiter)) { for (const ext of exts) { const path = join(dir, name + ext); - if (existsSync(path)) { + if (credentialFs.existsSync(path)) { return path; } } @@ -129,7 +152,7 @@ function findFile(dir: string, name: string, depth: number): string | null { return null; } try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { + for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isFile() && entry.name === name) { return path; diff --git a/extensions/google/oauth.test.ts b/extensions/google/oauth.test.ts index 8aec64d528d9..d37f0751dbe7 100644 --- a/extensions/google/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -21,23 +21,11 @@ vi.mock("../../src/infra/net/fetch-guard.js", () => ({ }, })); -// Mock fs module before importing the module under test const mockExistsSync = vi.fn(); const mockReadFileSync = vi.fn(); const mockRealpathSync = vi.fn(); const mockReaddirSync = vi.fn(); -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: (...args: Parameters) => mockExistsSync(...args), - readFileSync: (...args: Parameters) => mockReadFileSync(...args), - realpathSync: (...args: Parameters) => mockRealpathSync(...args), - readdirSync: (...args: Parameters) => mockReaddirSync(...args), - }; -}); - describe("extractGeminiCliCredentials", () => { const normalizePath = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); @@ -51,6 +39,20 @@ describe("extractGeminiCliCredentials", () => { let originalPath: string | undefined; + async function loadCredentialsModule() { + return await import("./oauth.credentials.js"); + } + + async function installMockFs() { + const { setOAuthCredentialsFsForTest } = await loadCredentialsModule(); + setOAuthCredentialsFsForTest({ + existsSync: (...args) => mockExistsSync(...args), + readFileSync: (...args) => mockReadFileSync(...args), + realpathSync: (...args) => mockRealpathSync(...args), + readdirSync: (...args) => mockReaddirSync(...args), + }); + } + function makeFakeLayout() { const binDir = join(rootDir, "fake", "bin"); const geminiPath = join(binDir, "gemini"); @@ -157,17 +159,20 @@ describe("extractGeminiCliCredentials", () => { beforeEach(async () => { vi.clearAllMocks(); originalPath = process.env.PATH; + await installMockFs(); }); - afterEach(() => { + afterEach(async () => { process.env.PATH = originalPath; + const { setOAuthCredentialsFsForTest } = await loadCredentialsModule(); + setOAuthCredentialsFsForTest(); }); it("returns null when gemini binary is not in PATH", async () => { process.env.PATH = "/nonexistent"; mockExistsSync.mockReturnValue(false); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -175,7 +180,7 @@ describe("extractGeminiCliCredentials", () => { it("extracts credentials from oauth2.js in known path", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); const result = extractGeminiCliCredentials(); @@ -185,7 +190,7 @@ describe("extractGeminiCliCredentials", () => { it("extracts credentials when PATH entry is an npm global shim", async () => { installNpmShimLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); const result = extractGeminiCliCredentials(); @@ -195,7 +200,7 @@ describe("extractGeminiCliCredentials", () => { it("returns null when oauth2.js cannot be found", async () => { installGeminiLayout({ oauth2Exists: false, readdir: [] }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -203,7 +208,7 @@ describe("extractGeminiCliCredentials", () => { it("returns null when oauth2.js lacks credentials", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); expect(extractGeminiCliCredentials()).toBeNull(); }); @@ -211,7 +216,7 @@ describe("extractGeminiCliCredentials", () => { it("caches credentials after first extraction", async () => { installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT }); - const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + const { extractGeminiCliCredentials, clearCredentialsCache } = await loadCredentialsModule(); clearCredentialsCache(); // First call diff --git a/extensions/googlechat/src/channel.outbound.test.ts b/extensions/googlechat/src/channel.outbound.test.ts index c9180dd8158f..b936a5e3139c 100644 --- a/extensions/googlechat/src/channel.outbound.test.ts +++ b/extensions/googlechat/src/channel.outbound.test.ts @@ -4,10 +4,14 @@ import { describe, expect, it, vi } from "vitest"; const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn()); const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn()); -vi.mock("./api.js", () => ({ - sendGoogleChatMessage: sendGoogleChatMessageMock, - uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock, -})); +vi.mock("./api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendGoogleChatMessage: sendGoogleChatMessageMock, + uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock, + }; +}); import { googlechatPlugin } from "./channel.js"; import { setGoogleChatRuntime } from "./runtime.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bde6311c7662..6f309a48bb80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,8 @@ importers: extensions/byteplus: {} + extensions/chutes: {} + extensions/cloudflare-ai-gateway: {} extensions/copilot-proxy: {} diff --git a/src/agents/chutes-models.test.ts b/src/agents/chutes-models.test.ts new file mode 100644 index 000000000000..66bafde50ade --- /dev/null +++ b/src/agents/chutes-models.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + buildChutesModelDefinition, + CHUTES_MODEL_CATALOG, + discoverChutesModels, + clearChutesModelCache, +} from "./chutes-models.js"; + +describe("chutes-models", () => { + beforeEach(() => { + clearChutesModelCache(); + }); + + it("buildChutesModelDefinition returns config with required fields", () => { + const entry = CHUTES_MODEL_CATALOG[0]; + const def = buildChutesModelDefinition(entry); + expect(def.id).toBe(entry.id); + expect(def.name).toBe(entry.name); + expect(def.reasoning).toBe(entry.reasoning); + expect(def.input).toEqual(entry.input); + expect(def.cost).toEqual(entry.cost); + expect(def.contextWindow).toBe(entry.contextWindow); + expect(def.maxTokens).toBe(entry.maxTokens); + expect(def.compat?.supportsUsageInStreaming).toBe(false); + }); + + it("discoverChutesModels returns static catalog when accessToken is empty", async () => { + const models = await discoverChutesModels(""); + expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length); + expect(models.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + }); + + it("discoverChutesModels returns static catalog in test env by default", async () => { + const models = await discoverChutesModels("test-token"); + expect(models).toHaveLength(CHUTES_MODEL_CATALOG.length); + expect(models[0]?.id).toBe("Qwen/Qwen3-32B"); + }); + + it("discoverChutesModels correctly maps API response when not in test env", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { id: "zai-org/GLM-4.7-TEE" }, + { + id: "new-provider/new-model-r1", + supported_features: ["reasoning"], + input_modalities: ["text", "image"], + context_length: 200000, + max_output_length: 16384, + pricing: { prompt: 0.1, completion: 0.2 }, + }, + { id: "new-provider/simple-model" }, + ], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const models = await discoverChutesModels("test-token-real-fetch"); + expect(models.length).toBeGreaterThan(0); + if (models.length === 3) { + expect(models[0]?.id).toBe("zai-org/GLM-4.7-TEE"); + expect(models[1]?.reasoning).toBe(true); + expect(models[1]?.compat?.supportsUsageInStreaming).toBe(false); + } + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("discoverChutesModels retries without auth on 401", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockImplementation((url, init) => { + if (init?.headers?.Authorization === "Bearer test-token-error") { + // pragma: allowlist secret + return Promise.resolve({ + ok: false, + status: 401, + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [ + { + id: "Qwen/Qwen3-32B", + name: "Qwen/Qwen3-32B", + supported_features: ["reasoning"], + input_modalities: ["text"], + context_length: 40960, + max_output_length: 40960, + pricing: { prompt: 0.08, completion: 0.24 }, + }, + { + id: "unsloth/Mistral-Nemo-Instruct-2407", + name: "unsloth/Mistral-Nemo-Instruct-2407", + input_modalities: ["text"], + context_length: 131072, + max_output_length: 131072, + pricing: { prompt: 0.02, completion: 0.04 }, + }, + { + id: "deepseek-ai/DeepSeek-V3-0324-TEE", + name: "deepseek-ai/DeepSeek-V3-0324-TEE", + supported_features: ["reasoning"], + input_modalities: ["text"], + context_length: 131072, + max_output_length: 65536, + pricing: { prompt: 0.28, completion: 0.42 }, + }, + ], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const models = await discoverChutesModels("test-token-error"); + expect(models.length).toBeGreaterThan(0); + expect(mockFetch).toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("caches fallback static catalog for non-OK responses", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const first = await discoverChutesModels("chutes-fallback-token"); + const second = await discoverChutesModels("chutes-fallback-token"); + expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id)); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("scopes discovery cache by access token", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization; + if (auth === "Bearer chutes-token-a") { + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "private/model-a" }], + }), + }); + } + if (auth === "Bearer chutes-token-b") { + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "private/model-b" }], + }), + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "public/model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + const modelsA = await discoverChutesModels("chutes-token-a"); + const modelsB = await discoverChutesModels("chutes-token-b"); + const modelsASecond = await discoverChutesModels("chutes-token-a"); + expect(modelsA[0]?.id).toBe("private/model-a"); + expect(modelsB[0]?.id).toBe("private/model-b"); + expect(modelsASecond[0]?.id).toBe("private/model-a"); + // One request per token, then cache hit for the repeated token-a call. + expect(mockFetch).toHaveBeenCalledTimes(2); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("evicts oldest token entries when cache reaches max size", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization ?? ""; + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: auth ? `${auth}-model` : "public-model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + for (let i = 0; i < 150; i += 1) { + await discoverChutesModels(`cache-token-${i}`); + } + + // The oldest key should have been evicted once we exceed the cap. + await discoverChutesModels("cache-token-0"); + expect(mockFetch).toHaveBeenCalledTimes(151); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); + + it("prunes expired token cache entries during subsequent discovery", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + const auth = init?.headers?.Authorization ?? ""; + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: auth ? `${auth}-model` : "public-model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + await discoverChutesModels("token-a"); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await discoverChutesModels("token-b"); + await discoverChutesModels("token-a"); + expect(mockFetch).toHaveBeenCalledTimes(3); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it("does not cache 401 fallback under the failed token key", async () => { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + const mockFetch = vi + .fn() + .mockImplementation((_url, init?: { headers?: Record }) => { + if (init?.headers?.Authorization === "Bearer failed-token") { + return Promise.resolve({ + ok: false, + status: 401, + }); + } + return Promise.resolve({ + ok: true, + json: async () => ({ + data: [{ id: "public/model" }], + }), + }); + }); + vi.stubGlobal("fetch", mockFetch); + + try { + await discoverChutesModels("failed-token"); + await discoverChutesModels("failed-token"); + // Two calls each perform: authenticated attempt (401) + public fallback. + expect(mockFetch).toHaveBeenCalledTimes(4); + } finally { + process.env.NODE_ENV = oldNodeEnv; + process.env.VITEST = oldVitest; + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/agents/chutes-models.ts b/src/agents/chutes-models.ts new file mode 100644 index 000000000000..585723e3adc2 --- /dev/null +++ b/src/agents/chutes-models.ts @@ -0,0 +1,639 @@ +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("chutes-models"); + +/** Chutes.ai OpenAI-compatible API base URL. */ +export const CHUTES_BASE_URL = "https://llm.chutes.ai/v1"; + +export const CHUTES_DEFAULT_MODEL_ID = "zai-org/GLM-4.7-TEE"; +export const CHUTES_DEFAULT_MODEL_REF = `chutes/${CHUTES_DEFAULT_MODEL_ID}`; + +/** Default cost for Chutes models (actual cost varies by model and compute). */ +export const CHUTES_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +/** Default context window and max tokens for discovered models. */ +const CHUTES_DEFAULT_CONTEXT_WINDOW = 128000; +const CHUTES_DEFAULT_MAX_TOKENS = 4096; + +/** + * Static catalog of popular Chutes models. + * Used as a fallback and for initial onboarding allowlisting. + */ +export const CHUTES_MODEL_CATALOG: ModelDefinitionConfig[] = [ + { + id: "Qwen/Qwen3-32B", + name: "Qwen/Qwen3-32B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.08, output: 0.24, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Mistral-Nemo-Instruct-2407", + name: "unsloth/Mistral-Nemo-Instruct-2407", + reasoning: false, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.02, output: 0.04, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3-0324-TEE", + name: "deepseek-ai/DeepSeek-V3-0324-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.25, output: 1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", + name: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.08, output: 0.55, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "openai/gpt-oss-120b-TEE", + name: "openai/gpt-oss-120b-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.05, output: 0.45, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "chutesai/Mistral-Small-3.1-24B-Instruct-2503", + name: "chutesai/Mistral-Small-3.1-24B-Instruct-2503", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.2-TEE", + name: "deepseek-ai/DeepSeek-V3.2-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.28, output: 0.42, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.7-TEE", + name: "zai-org/GLM-4.7-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "moonshotai/Kimi-K2.5-TEE", + name: "moonshotai/Kimi-K2.5-TEE", + reasoning: true, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 65535, + cost: { input: 0.45, output: 2.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-27b-it", + name: "unsloth/gemma-3-27b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 65536, + cost: { input: 0.04, output: 0.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "XiaomiMiMo/MiMo-V2-Flash-TEE", + name: "XiaomiMiMo/MiMo-V2-Flash-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.09, output: 0.29, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "chutesai/Mistral-Small-3.2-24B-Instruct-2506", + name: "chutesai/Mistral-Small-3.2-24B-Instruct-2506", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.06, output: 0.18, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-R1-0528-TEE", + name: "deepseek-ai/DeepSeek-R1-0528-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.45, output: 2.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-5-TEE", + name: "zai-org/GLM-5-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.95, output: 3.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.1-TEE", + name: "deepseek-ai/DeepSeek-V3.1-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.2, output: 0.8, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3.1-Terminus-TEE", + name: "deepseek-ai/DeepSeek-V3.1-Terminus-TEE", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 65536, + cost: { input: 0.23, output: 0.9, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-4b-it", + name: "unsloth/gemma-3-4b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 96000, + maxTokens: 96000, + cost: { input: 0.01, output: 0.03, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "MiniMaxAI/MiniMax-M2.5-TEE", + name: "MiniMaxAI/MiniMax-M2.5-TEE", + reasoning: true, + input: ["text"], + contextWindow: 196608, + maxTokens: 65536, + cost: { input: 0.3, output: 1.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "tngtech/DeepSeek-TNG-R1T2-Chimera", + name: "tngtech/DeepSeek-TNG-R1T2-Chimera", + reasoning: true, + input: ["text"], + contextWindow: 163840, + maxTokens: 163840, + cost: { input: 0.25, output: 0.85, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-Coder-Next-TEE", + name: "Qwen/Qwen3-Coder-Next-TEE", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.12, output: 0.75, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/Hermes-4-405B-FP8-TEE", + name: "NousResearch/Hermes-4-405B-FP8-TEE", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-V3", + name: "deepseek-ai/DeepSeek-V3", + reasoning: false, + input: ["text"], + contextWindow: 163840, + maxTokens: 163840, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "openai/gpt-oss-20b", + name: "openai/gpt-oss-20b", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.04, output: 0.15, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Llama-3.2-3B-Instruct", + name: "unsloth/Llama-3.2-3B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Mistral-Small-24B-Instruct-2501", + name: "unsloth/Mistral-Small-24B-Instruct-2501", + reasoning: false, + input: ["text", "image"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.07, output: 0.3, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.7-FP8", + name: "zai-org/GLM-4.7-FP8", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6-TEE", + name: "zai-org/GLM-4.6-TEE", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65536, + cost: { input: 0.4, output: 1.7, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3.5-397B-A17B-TEE", + name: "Qwen/Qwen3.5-397B-A17B-TEE", + reasoning: true, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 65536, + cost: { input: 0.55, output: 3.5, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-72B-Instruct", + name: "Qwen/Qwen2.5-72B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/DeepHermes-3-Mistral-24B-Preview", + name: "NousResearch/DeepHermes-3-Mistral-24B-Preview", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.02, output: 0.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-Next-80B-A3B-Instruct", + name: "Qwen/Qwen3-Next-80B-A3B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.1, output: 0.8, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6-FP8", + name: "zai-org/GLM-4.6-FP8", + reasoning: true, + input: ["text"], + contextWindow: 202752, + maxTokens: 65535, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-235B-A22B-Thinking-2507", + name: "Qwen/Qwen3-235B-A22B-Thinking-2507", + reasoning: true, + input: ["text"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.11, output: 0.6, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + name: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "tngtech/R1T2-Chimera-Speed", + name: "tngtech/R1T2-Chimera-Speed", + reasoning: true, + input: ["text"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.22, output: 0.6, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "zai-org/GLM-4.6V", + name: "zai-org/GLM-4.6V", + reasoning: true, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 65536, + cost: { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-VL-32B-Instruct", + name: "Qwen/Qwen2.5-VL-32B-Instruct", + reasoning: false, + input: ["text", "image"], + contextWindow: 16384, + maxTokens: 16384, + cost: { input: 0.05, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-VL-235B-A22B-Instruct", + name: "Qwen/Qwen3-VL-235B-A22B-Instruct", + reasoning: false, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 262144, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-14B", + name: "Qwen/Qwen3-14B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.05, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen2.5-Coder-32B-Instruct", + name: "Qwen/Qwen2.5-Coder-32B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 32768, + maxTokens: 32768, + cost: { input: 0.03, output: 0.11, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3-30B-A3B", + name: "Qwen/Qwen3-30B-A3B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.06, output: 0.22, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/gemma-3-12b-it", + name: "unsloth/gemma-3-12b-it", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.03, output: 0.1, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "unsloth/Llama-3.2-1B-Instruct", + name: "unsloth/Llama-3.2-1B-Instruct", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16-TEE", + name: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16-TEE", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.3, output: 1.2, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "NousResearch/Hermes-4-14B", + name: "NousResearch/Hermes-4-14B", + reasoning: true, + input: ["text"], + contextWindow: 40960, + maxTokens: 40960, + cost: { input: 0.01, output: 0.05, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "Qwen/Qwen3Guard-Gen-0.6B", + name: "Qwen/Qwen3Guard-Gen-0.6B", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "rednote-hilab/dots.ocr", + name: "rednote-hilab/dots.ocr", + reasoning: false, + input: ["text", "image"], + contextWindow: 131072, + maxTokens: 131072, + cost: { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 }, + }, +]; + +export function buildChutesModelDefinition( + model: (typeof CHUTES_MODEL_CATALOG)[number], +): ModelDefinitionConfig { + return { + ...model, + // Avoid usage-only streaming chunks that can break OpenAI-compatible parsers. + compat: { + supportsUsageInStreaming: false, + }, + }; +} + +interface ChutesModelEntry { + id: string; + name?: string; + supported_features?: string[]; + input_modalities?: string[]; + context_length?: number; + max_output_length?: number; + pricing?: { + prompt?: number; + completion?: number; + }; + [key: string]: unknown; +} + +interface OpenAIListModelsResponse { + data?: ChutesModelEntry[]; +} + +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const CACHE_MAX_ENTRIES = 100; + +interface CacheEntry { + models: ModelDefinitionConfig[]; + time: number; +} + +// Keyed by trimmed access token (empty string = unauthenticated). +// Prevents a public unauthenticated result from suppressing authenticated +// discovery for users with token-scoped private models. +const modelCache = new Map(); + +/** @internal - For testing only */ +export function clearChutesModelCache() { + modelCache.clear(); +} + +function pruneExpiredCacheEntries(now: number = Date.now()): void { + for (const [key, entry] of modelCache.entries()) { + if (now - entry.time >= CACHE_TTL) { + modelCache.delete(key); + } + } +} + +/** Cache the result for the given token key and return it. */ +function cacheAndReturn( + tokenKey: string, + models: ModelDefinitionConfig[], +): ModelDefinitionConfig[] { + const now = Date.now(); + pruneExpiredCacheEntries(now); + + if (!modelCache.has(tokenKey) && modelCache.size >= CACHE_MAX_ENTRIES) { + const oldest = modelCache.keys().next(); + if (!oldest.done) { + modelCache.delete(oldest.value); + } + } + + modelCache.set(tokenKey, { models, time: now }); + return models; +} + +/** + * Discover models from Chutes.ai API with fallback to static catalog. + * Mimics the logic in Chutes init script. + */ +export async function discoverChutesModels(accessToken?: string): Promise { + const trimmedKey = accessToken?.trim() ?? ""; + + // Return cached result for this token if still within TTL + const now = Date.now(); + pruneExpiredCacheEntries(now); + const cached = modelCache.get(trimmedKey); + if (cached) { + return cached.models; + } + + // Skip API discovery in test environment + if (process.env.NODE_ENV === "test" || process.env.VITEST === "true") { + return CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition); + } + + // If auth fails the result comes from the public endpoint — cache it under "" + // so the original token key stays uncached and retries cleanly next TTL window. + let effectiveKey = trimmedKey; + const staticCatalog = () => + cacheAndReturn(effectiveKey, CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition)); + + const headers: Record = {}; + if (trimmedKey) { + headers.Authorization = `Bearer ${trimmedKey}`; + } + + try { + let response = await fetch(`${CHUTES_BASE_URL}/models`, { + signal: AbortSignal.timeout(10_000), + headers, + }); + + if (response.status === 401 && trimmedKey) { + // Auth failed — fall back to the public (unauthenticated) endpoint. + // Cache the result under "" so the bad token stays uncached and can + // be retried with a refreshed credential after the TTL expires. + effectiveKey = ""; + response = await fetch(`${CHUTES_BASE_URL}/models`, { + signal: AbortSignal.timeout(10_000), + }); + } + + if (!response.ok) { + // Only log if it's not a common auth/overload error that we have a fallback for + if (response.status !== 401 && response.status !== 503) { + log.warn(`GET /v1/models failed: HTTP ${response.status}, using static catalog`); + } + return staticCatalog(); + } + + const body = (await response.json()) as OpenAIListModelsResponse; + const data = body?.data; + if (!Array.isArray(data) || data.length === 0) { + log.warn("No models in response, using static catalog"); + return staticCatalog(); + } + + const seen = new Set(); + const models: ModelDefinitionConfig[] = []; + + for (const entry of data) { + const id = typeof entry?.id === "string" ? entry.id.trim() : ""; + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + + const isReasoning = + entry.supported_features?.includes("reasoning") || + id.toLowerCase().includes("r1") || + id.toLowerCase().includes("thinking") || + id.toLowerCase().includes("reason") || + id.toLowerCase().includes("tee"); + + const input: Array<"text" | "image"> = (entry.input_modalities || ["text"]).filter( + (i): i is "text" | "image" => i === "text" || i === "image", + ); + + models.push({ + id, + name: id, // Mirror init.sh: uses id for name + reasoning: isReasoning, + input, + cost: { + input: entry.pricing?.prompt || 0, + output: entry.pricing?.completion || 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: entry.context_length || CHUTES_DEFAULT_CONTEXT_WINDOW, + maxTokens: entry.max_output_length || CHUTES_DEFAULT_MAX_TOKENS, + compat: { + supportsUsageInStreaming: false, + }, + }); + } + + return cacheAndReturn( + effectiveKey, + models.length > 0 ? models : CHUTES_MODEL_CATALOG.map(buildChutesModelDefinition), + ); + } catch (error) { + log.warn(`Discovery failed: ${String(error)}, using static catalog`); + return staticCatalog(); + } +} diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 8a890d3a6940..632970004a30 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -1,6 +1,7 @@ import type { SecretRefSource } from "../config/types.secrets.js"; import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; +export const CHUTES_OAUTH_MARKER = "chutes-oauth"; export const MINIMAX_OAUTH_MARKER = "minimax-oauth"; export const QWEN_OAUTH_MARKER = "qwen-oauth"; export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; @@ -69,6 +70,7 @@ export function isNonSecretApiKeyMarker( return false; } const isKnownMarker = + trimmed === CHUTES_OAUTH_MARKER || trimmed === MINIMAX_OAUTH_MARKER || trimmed === QWEN_OAUTH_MARKER || trimmed === OLLAMA_LOCAL_AUTH_MARKER || diff --git a/src/agents/models-config.providers.chutes.test.ts b/src/agents/models-config.providers.chutes.test.ts new file mode 100644 index 000000000000..774d484287e5 --- /dev/null +++ b/src/agents/models-config.providers.chutes.test.ts @@ -0,0 +1,128 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CHUTES_BASE_URL } from "./chutes-models.js"; +import { CHUTES_OAUTH_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; + +// Explicitly enable the bundled Chutes extension so resolveImplicitProviders +// loads the plugin's catalog. Bundled plugins not in BUNDLED_ENABLED_BY_DEFAULT +// require an explicit entries entry to activate, even in allowlist mode. +const CHUTES_PLUGIN_CONFIG = { + plugins: { + entries: { + chutes: { enabled: true }, + }, + }, +} as const; + +describe("chutes implicit provider auth mode", () => { + it("keeps api_key-backed chutes profiles on the api-key loader path", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ + agentDir, + env: {}, + config: CHUTES_PLUGIN_CONFIG, + }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); + + it("keeps api_key precedence when oauth profile is inserted first", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:oauth": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ + agentDir, + env: {}, + config: CHUTES_PLUGIN_CONFIG, + }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); + + it("keeps api_key precedence when api_key profile is inserted first", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "chutes:default": { + type: "api_key", + provider: "chutes", + key: "chutes-live-api-key", // pragma: allowlist secret + }, + "chutes:oauth": { + type: "oauth", + provider: "chutes", + access: "oauth-access-token", + refresh: "oauth-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ + agentDir, + env: {}, + config: CHUTES_PLUGIN_CONFIG, + }); + expect(providers?.chutes?.baseUrl).toBe(CHUTES_BASE_URL); + expect(providers?.chutes?.apiKey).toBe("chutes-live-api-key"); + expect(providers?.chutes?.apiKey).not.toBe(CHUTES_OAUTH_MARKER); + }); +}); diff --git a/src/commands/auth-choice.apply.oauth.ts b/src/commands/auth-choice.apply.oauth.ts index a2a3104e447c..ecb6e4db245a 100644 --- a/src/commands/auth-choice.apply.oauth.ts +++ b/src/commands/auth-choice.apply.oauth.ts @@ -1,3 +1,4 @@ +import { applyChutesConfig, applyChutesProviderConfig } from "../../extensions/chutes/onboard.js"; import { applyAuthProfileConfig, writeOAuthCredentials } from "../plugins/provider-auth-helpers.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { loginChutes } from "./chutes-oauth.js"; @@ -74,6 +75,14 @@ export async function applyAuthChoiceOAuth( provider: "chutes", mode: "oauth", }); + + // Register provider models and set default if this is the primary auth choice. + // This ensures the model picker shows a Chutes model pre-selected as current. + if (params.setDefaultModel) { + nextConfig = applyChutesConfig(nextConfig); + } else { + nextConfig = applyChutesProviderConfig(nextConfig); + } } catch (err) { spin.stop("Chutes OAuth failed"); params.runtime.error(String(err)); diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index d30dd81f7d64..271aac2f5291 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -15,6 +15,7 @@ export { upsertAuthProfile, } from "../agents/auth-profiles.js"; export { + CHUTES_OAUTH_MARKER, MINIMAX_OAUTH_MARKER, resolveNonEnvSecretRefApiKeyMarker, } from "../agents/model-auth-markers.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index c2a68c7b5798..996135c90111 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -81,6 +81,14 @@ export { buildHuggingfaceModelDefinition, } from "../agents/huggingface-models.js"; export { discoverKilocodeModels } from "../agents/kilocode-models.js"; +export { + buildChutesModelDefinition, + CHUTES_BASE_URL, + CHUTES_DEFAULT_MODEL_ID, + CHUTES_DEFAULT_MODEL_REF, + CHUTES_MODEL_CATALOG, + discoverChutesModels, +} from "../agents/chutes-models.js"; export { resolveOllamaApiBase } from "../agents/ollama-models.js"; export { buildSyntheticModelDefinition, diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 4339b6edec47..a87e632ac452 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -5,36 +5,57 @@ import { expectCodexMissingAuthHint, } from "../provider-runtime.test-support.js"; import { - providerContractPluginIds, + resolveProviderContractPluginIdsForProvider, resolveProviderContractProvidersForPluginIds, uniqueProviderContractProviders, } from "./registry.js"; -const resolvePluginProvidersMock = vi.fn(); -const resolveOwningPluginIdsForProviderMock = vi.fn(); -const resolveNonBundledProviderPluginIdsMock = vi.fn(); +type ResolvePluginProviders = typeof import("../providers.js").resolvePluginProviders; +type ResolveOwningPluginIdsForProvider = + typeof import("../providers.js").resolveOwningPluginIdsForProvider; +type ResolveNonBundledProviderPluginIds = + typeof import("../providers.js").resolveNonBundledProviderPluginIds; + +const resolvePluginProvidersMock = vi.hoisted(() => + vi.fn((_) => uniqueProviderContractProviders), +); +const resolveOwningPluginIdsForProviderMock = vi.hoisted(() => + vi.fn((params) => + resolveProviderContractPluginIdsForProvider(params.provider), + ), +); +const resolveNonBundledProviderPluginIdsMock = vi.hoisted(() => + vi.fn((_) => [] as string[]), +); vi.mock("../providers.js", () => ({ - resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), - resolveOwningPluginIdsForProvider: (...args: unknown[]) => - resolveOwningPluginIdsForProviderMock(...args), - resolveNonBundledProviderPluginIds: (...args: unknown[]) => - resolveNonBundledProviderPluginIdsMock(...args), + resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), + resolveNonBundledProviderPluginIds: (params: unknown) => + resolveNonBundledProviderPluginIdsMock(params as never), })); -const { - augmentModelCatalogWithProviderPlugins, - buildProviderMissingAuthMessageWithPlugin, - resetProviderRuntimeHookCacheForTest, - resolveProviderBuiltInModelSuppression, -} = await import("../provider-runtime.js"); +let augmentModelCatalogWithProviderPlugins: typeof import("../provider-runtime.js").augmentModelCatalogWithProviderPlugins; +let buildProviderMissingAuthMessageWithPlugin: typeof import("../provider-runtime.js").buildProviderMissingAuthMessageWithPlugin; +let resetProviderRuntimeHookCacheForTest: typeof import("../provider-runtime.js").resetProviderRuntimeHookCacheForTest; +let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.js").resolveProviderBuiltInModelSuppression; describe("provider catalog contract", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, + resetProviderRuntimeHookCacheForTest, + resolveProviderBuiltInModelSuppression, + } = await import("../provider-runtime.js")); resetProviderRuntimeHookCacheForTest(); resolveOwningPluginIdsForProviderMock.mockReset(); - resolveOwningPluginIdsForProviderMock.mockReturnValue(providerContractPluginIds); + resolveOwningPluginIdsForProviderMock.mockImplementation((params) => + resolveProviderContractPluginIdsForProvider(params.provider), + ); resolveNonBundledProviderPluginIdsMock.mockReset(); resolveNonBundledProviderPluginIdsMock.mockReturnValue([]); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index adedfe57d0ca..ca1b3d029b1e 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -2,6 +2,7 @@ import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js"; import anthropicPlugin from "../../../extensions/anthropic/index.js"; import bravePlugin from "../../../extensions/brave/index.js"; import byteplusPlugin from "../../../extensions/byteplus/index.js"; +import chutesPlugin from "../../../extensions/chutes/index.js"; import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js"; import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js"; import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; @@ -79,6 +80,7 @@ const bundledProviderPlugins: RegistrablePlugin[] = [ amazonBedrockPlugin, anthropicPlugin, byteplusPlugin, + chutesPlugin, cloudflareAiGatewayPlugin, copilotProxyPlugin, githubCopilotPlugin, @@ -177,6 +179,19 @@ export function requireProviderContractProvider(providerId: string): ProviderPlu return provider; } +export function resolveProviderContractPluginIdsForProvider( + providerId: string, +): string[] | undefined { + const pluginIds = [ + ...new Set( + providerContractRegistry + .filter((entry) => entry.provider.id === providerId) + .map((entry) => entry.pluginId), + ), + ]; + return pluginIds.length > 0 ? pluginIds : undefined; +} + export function resolveProviderContractProvidersForPluginIds( pluginIds: readonly string[], ): ProviderPlugin[] { diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 6ffa4d9a2d4c..1f70a9bc2794 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -457,7 +457,7 @@ export async function runSetupWizard( } } - if (authChoiceFromPrompt && authChoice !== "custom-api-key") { + if (authChoiceFromPrompt && authChoice !== "custom-api-key" && authChoice !== "skip") { const modelSelection = await promptDefaultModel({ config: nextConfig, prompter, diff --git a/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png b/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png deleted file mode 100644 index 6685d2ad9349..000000000000 Binary files a/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png and /dev/null differ