Compare commits

..

14 Commits

Author SHA1 Message Date
abeatrix 166b2e9fd4 test(test): fix Windows shell execution in bash tests
Update the bash executor test helper to prefix quoted executables with the PowerShell call operator on Windows. This ensures process.execPath is invoked correctly when paths contain spaces or are quoted, keeping cross-platform test behavior consistent.
2026-05-28 17:07:37 -07:00
Bee b87f61f9e4 fix(cli): stabilize core tests on Windows (#11125)
* fix(cli): stabilize core tests on Windows

Avoid several Windows-specific failure modes in the core CLI test suite.

Vitest already runs test files inside worker pools. The workspace file indexer was lazily spawning a nested worker from a transformed TypeScript module via import.meta.url, which is fragile on Windows and can cause Vitest to report only a generic worker fork crash. Disable that worker path under VITEST and use the deterministic fallback indexer for tests.

Quote process.execPath in bash executor shell-string tests. Windows Node/Bun paths commonly contain spaces, so unquoted command strings can fail under PowerShell or cmd even though they work on Unix paths.

Make detached hub probing defensive by routing probeHubServer calls through a safe wrapper, so rejected or malformed probe results are treated as unreachable instead of destabilizing startup/prewarm flows.

Also clear CLINE_RUN_AS_HUB_DAEMON in daemon test setup so tests do not inherit daemon-mode state from the surrounding CLI environment except where explicitly set.

Validation: bunx vitest run --config vitest.config.ts src/hub/daemon/index.test.ts src/services/workspace/file-indexer.test.ts src/services/workspace/mention-enricher.test.ts src/extensions/tools/executors/bash.test.ts --reporter=dot

Validation: bun run typecheck

Validation: bun run test:unit

* patch
2026-05-28 16:41:49 -07:00
Bee 38f1c7eb14 fix(cli): steer active connector sessions across turn keys (#11115)
* fix(cli): bind discord sessions to individual message authors

Resolve Discord participants from normalized message author data and persist
participant-specific thread state in bindings. Restore or create sessions per
participant so different Discord users do not accidentally share chat state.

Also add coverage for bot author handling and owner user configuration.

* patches

* fix(cli): steer active connector sessions across turn keys

Detect active connector turns by session ID when the current turn key
does not match, so replies steer the existing runtime session instead of
starting a duplicate session.

Also treat queued runtime turns as a non-error completion and log the
queued state for connector transports.

* fix(cli): steer active connector sessions across turn keys

Detect active connector turns by session ID when the current turn key
does not match, so replies steer the existing runtime session instead of
starting a duplicate session.

Also treat queued runtime turns as a non-error completion and log the
queued state for connector transports.

* add /idel
2026-05-28 13:21:09 -07:00
Robin Newhouse 81121663a4 fix(sdk): pin SAP AI provider for smoke install (#11116) 2026-05-28 13:02:26 -07:00
Bee 854ac75fe0 feat(cli): bind discord sessions to individual message authors (#11114)
* fix(cli): bind discord sessions to individual message authors

Resolve Discord participants from normalized message author data and persist
participant-specific thread state in bindings. Restore or create sessions per
participant so different Discord users do not accidentally share chat state.

Also add coverage for bot author handling and owner user configuration.

* patches
2026-05-28 12:53:37 -07:00
Ara 107f0f8337 bump version and update changelog (#11112) 2026-05-28 10:58:07 -07:00
Dominic Cooney e330695cb5 chore(codeowners): replace @candieduniverse with @dominiccooney (#11111)
Eve Killaby (@candieduniverse) has left Cline; transfer her /.github/ codeowner slot to @dominiccooney so .github changes still have four code owners able to approve.
2026-05-28 10:38:42 -07:00
Saoud Rizwan c2879dba43 feat(models): add Claude Opus 4.8 provider support (#11110)
Add claude-opus-4-8 (200k) and claude-opus-4-8:1m model variants across the
Anthropic, Claude Code, Bedrock, and Vertex catalogs, mirroring the Opus 4.7
setup (same pricing, 1M tiers, global endpoint, adaptive thinking).

- Wire the OpenRouter/Vercel AI Gateway 1m suffix handling and Cline/OpenRouter
  model refresh derivation for anthropic/claude-opus-4.8
- Register 4.8 in adaptive thinking detection so it uses the reasoning-effort
  selector path
- Bump the Claude Code "opus" alias to 4.8
- Add context window switchers in the Cline and OpenRouter model pickers
- Add provider tests for the new model ids
2026-05-28 10:37:44 -07:00
Mikołaj Kondratek f2d692cfc2 ci: gate ext-jb-test-integration auto-trigger on PR author association (#11108)
* ci: gate ext-jb-test-integration auto-trigger on PR author association

Extend the existing MEMBER/OWNER/COLLABORATOR allow-list (already used
for the /test-jetbrains comment path) to pull_request_target [opened,
reopened] as well, so the same trust model applies regardless of how
the workflow is triggered. PRs from non-trusted authors no longer
auto-trigger; a maintainer can still opt them in via /test-jetbrains.

* ci: replace hardcoded app-id with CLINE_JETBRAINS_WORKFLOW_ID var

Matches the convention already in use in cline/intellij-plugin and lets us change the App ID without touching workflow code.

* Update .github/workflows/ext-jb-test-integration.yml

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* ci: rename CLINE_JETBRAINS_WORKFLOW_KEY to CLINE_JETBRAINS_APP_KEY

The secret holds a GitHub App private key. Matches the rename of the matching app-id var in the previous commit.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-28 10:36:38 -07:00
Ara 0c90bd9bcf feat: add Moonshot Kimi K2.6 model (#11109) 2026-05-28 10:17:50 -07:00
Bee 33e521e551 fix: SAP AI Core uses AI SDK community provider (CLINE-2307) (#11075) 2026-05-27 20:12:01 -07:00
Ara 9e942fbb6b Fix Discord connector registration (#11077)
* fix(cli): register discord connector

* fix(cli): scope Discord reply fallback

* docs(cli): expand Discord connector setup

* fix(cli): move Discord empty reply fallback to adapter
2026-05-27 18:47:20 -07:00
Bee 6f609e8945 fix: writeDiagnostic for logging ACP output (#11091)
Replace writeErr with writeDiagnostic for logging ACP output so that they don't show up as error.
2026-05-27 14:14:49 -07:00
Ara 762e3c42ab fix(vscode): show Qwen 3.7 Max cache support (#11079)
* fix(vscode): route Qwen cache requests

* fix(vscode): keep qwen cache alias request-scoped

* fix(vscode): mark Vercel prompt-cache models

* fix(vscode): show Qwen 3.7 Max cache support
2026-05-27 13:39:28 -07:00
67 changed files with 2977 additions and 138 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @candieduniverse
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
/README.md @saoudrizwan @juanpflores
@@ -15,9 +15,11 @@ jobs:
trigger-integration-test:
name: Run Tests
runs-on: ubuntu-latest
# Run on PR open/reopen, or when someone comments /test-jetbrains on a PR
# Auto-run only for trusted PR authors. Anyone else needs a maintainer
# to opt their PR in by commenting /test-jetbrains.
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'pull_request_target' &&
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.pull_request.author_association)) ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/test-jetbrains') &&
@@ -27,8 +29,8 @@ jobs:
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: 1998650
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
app-id: ${{ vars.CLINE_JETBRAINS_APP_ID }}
private-key: ${{ secrets.CLINE_JETBRAINS_APP_KEY }}
owner: cline
repositories: intellij-plugin
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [3.86.0]
### Added
- Add Claude Opus 4.8 provider support, including 1M-context variants where available.
- Add Moonshot Kimi K2.6 model support.
### Fixed
- Show prompt-cache support for Qwen 3.7 Max in the Cline provider.
- Fix the VS Code nightly publish workflow startup permissions.
### Changed
- Move the VS Code extension project into `apps/vscode`.
## [3.85.0]
### Added
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.85.0",
"version": "3.86.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.85.0",
"version": "3.86.0",
"license": "Apache-2.0",
"workspaces": [
"."
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.85.0",
"version": "3.86.0",
"icon": "assets/icons/icon.png",
"workspaces": [
"."
@@ -63,6 +63,30 @@ describe("AnthropicHandler", () => {
result.id.should.equal("claude-opus-4-7:1m")
result.info.should.deepEqual(anthropicModels["claude-opus-4-7:1m"])
})
it("should return the 4.8 model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-8",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-4-8")
result.info.should.deepEqual(anthropicModels["claude-opus-4-8"])
})
it("should return the 4.8 1m model when configured", () => {
const handler = new AnthropicHandler({
apiKey: "test-api-key",
apiModelId: "claude-opus-4-8:1m",
})
const result = handler.getModel()
result.id.should.equal("claude-opus-4-8:1m")
result.info.should.deepEqual(anthropicModels["claude-opus-4-8:1m"])
})
})
describe("createMessage", () => {
@@ -210,12 +210,24 @@ describe("AwsBedrockHandler", () => {
bedrockModels["anthropic.claude-opus-4-7:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should mark Bedrock Opus 4.8 variants as global-endpoint capable", () => {
bedrockModels["anthropic.claude-opus-4-8"].supportsGlobalEndpoint.should.equal(true)
bedrockModels["anthropic.claude-opus-4-8:1m"].supportsGlobalEndpoint.should.equal(true)
})
it("should include Vertex Opus 4.7 variants in the derived global model list", () => {
vertexModels["claude-opus-4-7"].supportsGlobalEndpoint.should.equal(true)
vertexModels["claude-opus-4-7:1m"].supportsGlobalEndpoint.should.equal(true)
vertexGlobalModels.should.have.property("claude-opus-4-7")
vertexGlobalModels.should.have.property("claude-opus-4-7:1m")
})
it("should include Vertex Opus 4.8 variants in the derived global model list", () => {
vertexModels["claude-opus-4-8"].supportsGlobalEndpoint.should.equal(true)
vertexModels["claude-opus-4-8:1m"].supportsGlobalEndpoint.should.equal(true)
vertexGlobalModels.should.have.property("claude-opus-4-8")
vertexGlobalModels.should.have.property("claude-opus-4-8:1m")
})
})
const mockOptions: AwsBedrockHandlerOptions = {
@@ -396,6 +396,26 @@ describe("ClaudeCodeHandler", () => {
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Opus 4.8 model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-4-8",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-4-8")
model.info.contextWindow.should.equal(200_000)
})
it("should support Opus 4.8 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-opus-4-8[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-opus-4-8[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Opus 1m alias model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "opus[1m]",
@@ -136,4 +136,31 @@ describe("ClineHandler", () => {
const payload = createStub.firstCall.args[0]
payload.parallel_tool_calls.should.equal(true)
})
it("should send cache_control for qwen3.7-max without changing the selected Cline model id", async () => {
const handler = createHandler({
openRouterModelId: "qwen/qwen3.7-max",
openRouterModelInfo: openRouterDefaultModelInfo,
})
const createStub = sinon.stub().resolves(createAsyncIterable([]))
const fakeClient = {
chat: {
completions: {
create: createStub,
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
// drain stream
}
handler.getModel().id.should.equal("qwen/qwen3.7-max")
const payload = createStub.firstCall.args[0]
payload.model.should.equal("qwen/qwen3.7-max")
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
})
})
@@ -0,0 +1,53 @@
import "should"
import { moonshotModels } from "@shared/api"
import type { ClineStorageMessage } from "@shared/messages/content"
import sinon from "sinon"
import { MoonshotHandler } from "../moonshot"
interface MoonshotRequestPayload {
model: string
temperature: number
max_tokens: number
}
describe("MoonshotHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: unknown[] = []): AsyncIterable<unknown> => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("supports kimi-k2.6 model metadata", async () => {
const handler = new MoonshotHandler({
moonshotApiKey: "test-api-key",
apiModelId: "kimi-k2.6",
})
const model = handler.getModel()
model.id.should.equal("kimi-k2.6")
model.info.should.deepEqual(moonshotModels["kimi-k2.6"])
const createStub = sinon.stub().resolves(createAsyncIterable([]))
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
chat: {
completions: {
create: createStub,
},
},
})
const messages: ClineStorageMessage[] = [{ role: "user", content: "hi" }]
for await (const _chunk of handler.createMessage("system", messages)) {
// Consume stream to trigger request execution.
}
const payload = createStub.firstCall.args[0] as MoonshotRequestPayload
payload.model.should.equal("kimi-k2.6")
payload.temperature.should.equal(moonshotModels["kimi-k2.6"].temperature)
payload.max_tokens.should.equal(moonshotModels["kimi-k2.6"].maxTokens)
})
})
@@ -79,16 +79,18 @@ describe("createOpenRouterStream", () => {
})
it("adds cache_control blocks for Qwen models that require explicit OpenRouter caching", async () => {
const { client, create } = createClient()
for (const modelId of ["qwen/qwen3.6-plus", "qwen/qwen3.7-max"]) {
const { client, create } = createClient()
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
id: "qwen/qwen3.6-plus",
info: createModelInfo(65_536),
})
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
id: modelId,
info: createModelInfo(65_536),
})
const payload = create.firstCall.args[0] as any
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
const payload = create.firstCall.args[0] as any
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
}
})
it("uses adaptive reasoning with verbosity for Claude Opus adaptive models", async () => {
@@ -5,6 +5,7 @@ import {
OPENROUTER_PROVIDER_PREFERENCES,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
@@ -28,6 +29,7 @@ const openRouterExplicitCacheControlModelIds = new Set([
"qwen/qwen-plus",
"qwen/qwen3-max",
"qwen/qwen3.6-plus",
"qwen/qwen3.7-max",
"qwen/qwen3-coder-plus",
"qwen/qwen3-coder-flash",
])
@@ -60,7 +62,8 @@ export async function createOpenRouterStream(
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeOpus461mModelId ||
model.id === openRouterClaudeOpus471mModelId
model.id === openRouterClaudeOpus471mModelId ||
model.id === openRouterClaudeOpus481mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id openrouter API expects
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
@@ -4,6 +4,7 @@ import {
ModelInfo,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
@@ -37,7 +38,8 @@ export async function createVercelAIGatewayStream(
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeOpus461mModelId ||
model.id === openRouterClaudeOpus471mModelId
model.id === openRouterClaudeOpus471mModelId ||
model.id === openRouterClaudeOpus481mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id the API expects
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
@@ -0,0 +1,77 @@
import * as disk from "@core/storage/disk"
import axios from "axios"
import { expect } from "chai"
import fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineEnv, Environment } from "@/config"
import { StateManager } from "@/core/storage/StateManager"
import { getFeatureFlagsService } from "@/services/feature-flags"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { refreshClineModels } from "../refreshClineModels"
describe("refreshClineModels", () => {
let sandbox: sinon.SinonSandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
sandbox.stub(Logger, "log")
sandbox.stub(Logger, "error")
})
afterEach(() => {
sandbox.restore()
})
it("marks Qwen 3.7 Max as prompt-cache capable when Cline model pricing includes cache reads", async () => {
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").callsFake((flag) => {
return flag === FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT
})
sandbox.stub(ClineEnv, "config").returns({
environment: Environment.production,
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(StateManager, "get").returns({
getModelsCache: () => null,
setModelsCache: () => {},
} as any)
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
sandbox.stub(axios, "get").resolves({
data: {
data: [
{
id: "qwen/qwen3.7-max",
name: "Qwen: Qwen3.7 Max",
description: null,
context_length: 1_000_000,
top_provider: {
max_completion_tokens: 65_536,
context_length: 1_000_000,
is_moderated: false,
},
architecture: {
modality: "text->text",
},
pricing: {
prompt: "0.00000125",
completion: "0.00000375",
input_cache_read: "0.00000025",
},
supported_parameters: ["include_reasoning", "reasoning"],
},
],
},
})
const models = await refreshClineModels({} as any)
const qwen37 = models["qwen/qwen3.7-max"]
expect(qwen37.supportsPromptCache).to.equal(true)
expect(qwen37.cacheReadsPrice).to.equal(0.25)
expect(qwen37.cacheWritesPrice).to.equal(undefined)
})
})
@@ -15,6 +15,7 @@ import {
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
@@ -172,6 +173,10 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
tiers: rawModel.tiers ?? undefined,
}
if (modelInfo.cacheReadsPrice !== undefined || modelInfo.cacheWritesPrice !== undefined) {
modelInfo.supportsPromptCache = true
}
// Apply model-specific overrides for known models
switch (rawModel.id) {
case "anthropic/claude-sonnet-4.6":
@@ -193,6 +198,7 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
break
case "anthropic/claude-opus-4.6":
case "anthropic/claude-opus-4.7":
case "anthropic/claude-opus-4.8":
modelInfo.contextWindow = 200_000
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 6.25
@@ -274,8 +280,12 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
}
}
// Add custom :1m model variant for Opus 4.6 and 4.7
if (rawModel.id === "anthropic/claude-opus-4.6" || rawModel.id === "anthropic/claude-opus-4.7") {
// Add custom :1m model variant for Opus 4.6, 4.7 and 4.8
if (
rawModel.id === "anthropic/claude-opus-4.6" ||
rawModel.id === "anthropic/claude-opus-4.7" ||
rawModel.id === "anthropic/claude-opus-4.8"
) {
const claudeOpus1mModelInfo = cloneDeep(modelInfo)
claudeOpus1mModelInfo.contextWindow = 1_000_000
claudeOpus1mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
@@ -285,6 +295,9 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
if (rawModel.id === "anthropic/claude-opus-4.7") {
models[openRouterClaudeOpus471mModelId] = claudeOpus1mModelInfo
}
if (rawModel.id === "anthropic/claude-opus-4.8") {
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
}
}
}
if (Object.keys(models).length === 0) {
@@ -12,6 +12,7 @@ import {
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeOpus461mModelId,
openRouterClaudeOpus471mModelId,
openRouterClaudeOpus481mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
@@ -172,6 +173,7 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
break
case "anthropic/claude-opus-4.6":
case "anthropic/claude-opus-4.7":
case "anthropic/claude-opus-4.8":
modelInfo.contextWindow = 200_000 // restrict to 200k, 1m variant created below
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 6.25
@@ -301,8 +303,12 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
}
}
// add custom :1m model variant for opus 4.6 and 4.7
if (rawModel.id === "anthropic/claude-opus-4.6" || rawModel.id === "anthropic/claude-opus-4.7") {
// add custom :1m model variant for opus 4.6, 4.7 and 4.8
if (
rawModel.id === "anthropic/claude-opus-4.6" ||
rawModel.id === "anthropic/claude-opus-4.7" ||
rawModel.id === "anthropic/claude-opus-4.8"
) {
const claudeOpus1mModelInfo = cloneDeep(modelInfo)
claudeOpus1mModelInfo.contextWindow = 1_000_000
claudeOpus1mModelInfo.tiers = CLAUDE_OPUS_1M_TIERS
@@ -312,6 +318,9 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
if (rawModel.id === "anthropic/claude-opus-4.7") {
models[openRouterClaudeOpus471mModelId] = claudeOpus1mModelInfo
}
if (rawModel.id === "anthropic/claude-opus-4.8") {
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
}
}
}
// Save models and cache them in memory
+97 -2
View File
@@ -295,6 +295,29 @@ export const anthropicModels = {
description:
"Anthropic fast mode preview for Claude Opus 4.6 with the 1M context beta enabled. Same model and capabilities with higher output token speed at premium pricing across the full 1M context window. Requires both fast mode and 1M context access on your Anthropic account.",
},
"claude-opus-4-8": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
},
"claude-opus-4-8:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-7": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -421,13 +444,13 @@ export const claudeCodeModels = {
supportsPromptCache: false,
},
opus: {
...anthropicModels["claude-opus-4-7"],
...anthropicModels["claude-opus-4-8"],
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
},
"opus[1m]": {
...anthropicModels["claude-opus-4-7:1m"],
...anthropicModels["claude-opus-4-8:1m"],
supportsImages: false,
supportsPromptCache: false,
},
@@ -471,6 +494,17 @@ export const claudeCodeModels = {
supportsImages: false,
supportsPromptCache: false,
},
"claude-opus-4-8": {
...anthropicModels["claude-opus-4-8"],
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
},
"claude-opus-4-8[1m]": {
...anthropicModels["claude-opus-4-8:1m"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-opus-4-7": {
...anthropicModels["claude-opus-4-7"],
contextWindow: 200_000,
@@ -626,6 +660,31 @@ export const bedrockModels = {
cacheReadsPrice: 0.5,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"anthropic.claude-opus-4-8": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
},
"anthropic.claude-opus-4-8:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"anthropic.claude-opus-4-7": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -862,6 +921,7 @@ export const openRouterClaudeSonnet451mModelId = `anthropic/claude-sonnet-4.5${C
export const openRouterClaudeSonnet461mModelId = `anthropic/claude-sonnet-4.6${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus461mModelId = `anthropic/claude-opus-4.6${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus471mModelId = `anthropic/claude-opus-4.7${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterClaudeOpus481mModelId = `anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`
export const openRouterDefaultModelInfo: ModelInfo = {
maxTokens: 64_000,
contextWindow: 200_000,
@@ -1122,6 +1182,31 @@ export const vertexModels = {
supportsReasoning: true,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-8": {
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
supportsReasoning: true,
},
"claude-opus-4-8:1m": {
maxTokens: 128_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
supportsGlobalEndpoint: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 6.25,
cacheReadsPrice: 0.5,
supportsReasoning: true,
tiers: CLAUDE_OPUS_1M_TIERS,
},
"claude-opus-4-7": {
maxTokens: 128_000,
contextWindow: 200_000,
@@ -4510,6 +4595,16 @@ export const sapAiCoreModels = {
// Moonshot AI Studio
// https://platform.moonshot.ai/docs/pricing/chat
export const moonshotModels = {
"kimi-k2.6": {
maxTokens: 32_000,
contextWindow: 262_144,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.95,
outputPrice: 4.0,
cacheReadsPrice: 0.16,
temperature: 1.0,
},
"kimi-k2.5": {
maxTokens: 32_000,
contextWindow: 262_144,
@@ -11,7 +11,7 @@ export function isClaudeOpusAdaptiveThinkingModel(modelId?: string): boolean {
}
const id = modelId.toLowerCase()
const adaptiveVersions = ["4-6", "4.6", "4-7", "4.7"]
const adaptiveVersions = ["4-6", "4.6", "4-7", "4.7", "4-8", "4.8"]
return adaptiveVersions.some((version) => id.includes(`claude-opus-${version}`) || id.includes(`claude-${version}-opus`))
}
@@ -512,6 +512,14 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
)}
</DropdownWrapper>
{/* Context window switcher for Claude Opus 4.8 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-opus-4.8"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 4.7 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.7${CLAUDE_SONNET_1M_SUFFIX}`}
@@ -323,6 +323,14 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
)}
</DropdownWrapper>
{/* Context window switcher for Claude Opus 4.8 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`}
base200kModelId="anthropic/claude-opus-4.8"
onModelChange={handleModelChange}
selectedModelId={selectedModelId}
/>
{/* Context window switcher for Claude Opus 4.7 */}
<ContextWindowSwitcher
base1mModelId={`anthropic/claude-opus-4.7${CLAUDE_SONNET_1M_SUFFIX}`}
@@ -13,6 +13,7 @@ const SUPPORTED_CLAUDE_CODE_THINKING_MODELS = [
...SUPPORTED_ANTHROPIC_THINKING_MODELS,
"sonnet",
"sonnet[1m]",
"claude-opus-4-8[1m]",
"claude-opus-4-7[1m]",
"claude-sonnet-4-6[1m]",
"claude-sonnet-4-5-20250929[1m]",
+107 -2
View File
@@ -85,10 +85,115 @@ Each Slack thread maps to an agent session, so the agent maintains conversation
## Discord
Requires an application ID, bot token, public key, and public base URL.
Requires a Discord application ID, bot token, public key, and public base URL.
The connector listens for Discord interactions at `/api/webhooks/discord` and
also starts a Discord gateway listener for mentions, replies, reactions, and DMs.
<Steps>
<Step title="Create a Discord application and bot">
Open [Discord Developer Portal](https://discord.com/developers/applications)
and create an application.
1. In **General Information**, copy the **Application ID** and **Public Key**.
2. In **Bot**, create a bot if one does not exist, then reset and copy the bot token.
3. Enable **Message Content Intent** if you want normal messages, replies, and DMs to include text content.
</Step>
<Step title="Expose a public base URL">
For local development, use a tunnel such as ngrok:
```bash
ngrok http 8788
```
Copy the HTTPS forwarding URL. This is your connector base URL, for example
`https://1234-5678.ngrok-free.app`.
</Step>
<Step title="Start the connector">
```bash
cline connect discord \
--application-id <ID> \
--bot-token <TOKEN> \
--public-key <KEY> \
--base-url <URL> \
--port 8788 \
--cwd /path/to/repo \
--enable-tools
```
`--app-id` is an alias for `--application-id`, and `--token` is an alias for
`--bot-token`.
`--enable-tools` allows the agent to inspect files, run commands, edit code,
and prepare PRs from Discord. Omit it if the bot should only chat.
</Step>
<Step title="Configure the Discord interactions endpoint">
In the Discord Developer Portal, set **Interactions Endpoint URL** to:
```text
<base-url>/api/webhooks/discord
```
For example:
```text
https://1234-5678.ngrok-free.app/api/webhooks/discord
```
You can verify the connector is reachable with:
```bash
curl <base-url>/health
```
</Step>
<Step title="Invite the bot to a test server">
In **OAuth2 > URL Generator**, select the `bot` and
`applications.commands` scopes, then give the bot permission to send
messages and read message history. Open the generated URL and install the bot
into your test server.
</Step>
<Step title="Chat with the bot">
Mention the bot in a server channel, reply in a bot-created thread, or DM the
bot. Each Discord conversation keeps its own agent session and context.
</Step>
</Steps>
### Discord Command Reference
Send these commands in Discord:
| Command | Description |
|---------|-------------|
| `/help` or `/start` | Show connector help |
| `/new` or `/clear` | Start a fresh session for this Discord conversation |
| `/whereami` | Show thread, channel, DM state, `cwd`, `workspaceRoot`, tools, and yolo state |
| `/tools [on\|off\|toggle]` | View or change whether repo/file/shell tools are allowed |
| `/yolo [on\|off\|toggle]` | View or change automatic tool approval |
| `/cwd [path]` | View or change the working directory for this conversation |
| `/schedule create/list/trigger/delete` | Manage scheduled workflows targeting this conversation |
| `/abort` | Stop the current task |
| `/exit` | Stop the connector |
Normal messages are treated as agent tasks. If a task is already running, normal
messages steer the active task.
### Discord Security
By default, anyone who can reach the bot can ask it to run tasks. Restrict access
with `--hook-command`. The hook receives the Discord user as a participant key
such as `discord:user:123456789`.
```bash
cline connect discord --app-id <ID> --token <TOKEN> --public-key <KEY> --base-url <URL>
cline connect discord \
--application-id <ID> \
--bot-token <TOKEN> \
--public-key <KEY> \
--base-url <URL> \
--hook-command 'jq -r ".payload.actor.participantKey" | grep -q "discord:user:123456789" && echo "{\"action\":\"allow\"}" || echo "{\"action\":\"deny\",\"message\":\"unauthorized\"}"'
```
## Google Chat
+3
View File
@@ -42,6 +42,9 @@ pnpm-lock.yaml
# Session files / User data
.cline/data
.cline/tmp
.cline/**/managed.json
.cline/**/bundle.json
*.db
*.db-shm
*.db-wal
+6 -6
View File
@@ -6,7 +6,7 @@ import {
saveOAuthProviderSettings,
toProviderApiKey,
} from "../commands/auth";
import { writeErr } from "../utils/output";
import { writeDiagnostic } from "../utils/output";
/**
* Supported ACP OAuth provider IDs.
@@ -73,10 +73,10 @@ async function performOAuthLogin(
),
);
},
onOutput: (message) => writeErr(`[acp/auth] ${message}`),
onOutput: (message) => writeDiagnostic(`[acp/auth] ${message}`),
openUrl: (url) => open(url, { wait: false }).then(() => undefined),
onOpenUrlError: ({ url }) => {
writeErr(
writeDiagnostic(
`[acp/auth] Could not open browser automatically. Open this URL manually:\n${url}`,
);
},
@@ -116,12 +116,12 @@ export async function authenticateAcpProvider(
// Check for already-stored credentials.
const existingKey = getPersistedProviderApiKey(methodId, existing);
if (existingKey) {
writeErr(`[acp/auth] Using existing credentials for ${methodId}`);
writeDiagnostic(`[acp/auth] Using existing credentials for ${methodId}`);
return { providerId: methodId, apiKey: existingKey };
}
// Perform a fresh OAuth login.
writeErr(`[acp/auth] Starting OAuth login for ${methodId}`);
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}`);
const credentials = await performOAuthLogin(methodId, existing);
saveOAuthProviderSettings(
@@ -132,6 +132,6 @@ export async function authenticateAcpProvider(
);
const apiKey = toProviderApiKey(methodId, credentials);
writeErr(`[acp/auth] Successfully authenticated with ${methodId}`);
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
return { providerId: methodId, apiKey };
}
+36
View File
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from "vitest";
describe("runAcpMode", () => {
afterEach(() => {
vi.doUnmock("@agentclientprotocol/sdk");
vi.doUnmock("./acpAgent");
vi.restoreAllMocks();
});
it("writes the startup diagnostic without labeling it as an error", async () => {
const stderrWrite = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
vi.doMock("@agentclientprotocol/sdk", () => ({
ndJsonStream: vi.fn(() => ({})),
AgentSideConnection: class {
closed = Promise.resolve();
},
}));
vi.doMock("./acpAgent", () => ({
AcpAgent: class {},
}));
const { runAcpMode } = await import("./index");
await runAcpMode();
expect(stderrWrite).toHaveBeenCalledWith(
"[acp] starting ACP mode over stdio…\n",
);
expect(stderrWrite).not.toHaveBeenCalledWith(
expect.stringContaining("error:"),
);
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { Readable, Writable } from "node:stream";
import { writeErr } from "../utils/output";
import { writeDiagnostic } from "../utils/output";
export async function runAcpMode(): Promise<void> {
const { AgentSideConnection, ndJsonStream } = await import(
@@ -7,7 +7,7 @@ export async function runAcpMode(): Promise<void> {
);
const { AcpAgent } = await import("./acpAgent");
writeErr("[acp] starting ACP mode over stdio…");
writeDiagnostic("[acp] starting ACP mode over stdio…");
const stream = ndJsonStream(
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
@@ -0,0 +1,600 @@
import { writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectDiscordOptions } from "@cline/shared";
import type { Thread } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { readBindings, writeBindings } from "../thread-bindings";
import { __test__, discordConnector } from "./discord";
const parseDiscordArgs = (rawArgs: string[]): ConnectDiscordOptions =>
(
discordConnector as unknown as {
parseArgs(rawArgs: string[]): ConnectDiscordOptions;
}
).parseArgs(rawArgs);
type TestDiscordState = {
sessionId?: string;
enableTools?: boolean;
autoApproveTools?: boolean;
cwd?: string;
workspaceRoot?: string;
systemPrompt?: string;
participantKey?: string;
participantLabel?: string;
welcomeSentAt?: string;
};
function createThread(
initialState: TestDiscordState,
): Thread<TestDiscordState> {
let state = { ...initialState };
return {
id: "discord:guild:channel:thread",
channelId: "discord:guild:channel",
isDM: false,
get state() {
return Promise.resolve(state);
},
async setState(nextState: TestDiscordState) {
state = { ...nextState };
},
toJSON() {
return {
id: "discord:guild:channel:thread",
channelId: "discord:guild:channel",
isDM: false,
state,
};
},
} as unknown as Thread<TestDiscordState>;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("discordConnector", () => {
it("accepts the documented app id and token aliases", () => {
const options = parseDiscordArgs([
"--app-id",
"app-123",
"--token",
"bot-token",
"--public-key",
"public-key",
"--base-url",
"https://example.test",
]);
expect(options.applicationId).toBe("app-123");
expect(options.botToken).toBe("bot-token");
expect(options.publicKey).toBe("public-key");
expect(options.baseUrl).toBe("https://example.test");
});
it("keeps accepting the explicit application id and bot token options", () => {
const options = parseDiscordArgs([
"--application-id",
"app-456",
"--bot-token",
"other-token",
"--public-key",
"public-key",
"--owner-user-id",
"owner-123",
]);
expect(options.applicationId).toBe("app-456");
expect(options.botToken).toBe("other-token");
expect(options.ownerUserId).toBe("owner-123");
expect(options.allowBotAuthors).toBe(true);
});
it("can explicitly ignore bot-authored Discord messages", () => {
const options = parseDiscordArgs([
"--application-id",
"app-456",
"--bot-token",
"other-token",
"--public-key",
"public-key",
"--ignore-bot-authors",
]);
expect(options.allowBotAuthors).toBe(false);
});
it("builds empty-runtime fallback replies from the current Discord turn", async () => {
const priorMessages = [
{
role: "user",
content: [{ type: "text", text: "previous question" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Previous reply." }],
},
];
const currentMessages = [
...priorMessages,
{
role: "user",
content: [{ type: "text", text: "read README.md" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Summary from saved session." }],
},
];
const client = {
readMessages: vi
.fn()
.mockResolvedValueOnce(priorMessages)
.mockResolvedValueOnce(currentMessages),
};
const resolveFallbackText =
await __test__.createDiscordEmptyRuntimeReplyResolver({
client: client as never,
sessionId: "session-1",
});
await expect(resolveFallbackText?.()).resolves.toBe(
"Summary from saved session.",
);
expect(client.readMessages).toHaveBeenCalledTimes(2);
});
it("does not reuse prior Discord replies as empty-runtime fallback text", async () => {
const priorMessages = [
{
role: "user",
content: [{ type: "text", text: "previous question" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Previous reply." }],
},
];
const currentMessages = [
...priorMessages,
{
role: "user",
content: [{ type: "text", text: "run ls /tmp" }],
},
{
role: "tool",
content: [{ type: "text", text: "tool output" }],
},
];
const client = {
readMessages: vi
.fn()
.mockResolvedValueOnce(priorMessages)
.mockResolvedValueOnce(currentMessages),
};
const resolveFallbackText =
await __test__.createDiscordEmptyRuntimeReplyResolver({
client: client as never,
sessionId: "session-1",
});
await expect(resolveFallbackText?.()).resolves.toBeUndefined();
expect(client.readMessages).toHaveBeenCalledTimes(2);
});
it("resolves Discord participants from normalized gateway message authors", () => {
expect(
__test__.resolveDiscordParticipant(
{
content: "<@1509620637721821224> Heyo",
author: {
id: "bot-message-author-should-not-win",
username: "beebot",
},
},
{
userId: "850213762576810065",
userName: "alice",
fullName: "Alice Example",
},
),
).toEqual({
key: "discord:user:850213762576810065",
label: "Alice Example",
});
});
it("resolves Discord interaction users even when raw.data is command data", () => {
expect(
__test__.resolveDiscordParticipant({
id: "interaction-1",
data: { name: "ask" },
member: {
user: {
id: "488220547356950529",
username: "bob",
global_name: "Bob Example",
},
},
}),
).toEqual({
key: "discord:user:488220547356950529",
label: "Bob Example",
});
});
it("switches Discord thread state to the incoming participant without reusing the previous participant session", async () => {
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
const bindingsPath = join(dir, "threads.json");
const thread = createThread({
sessionId: "session-alice",
participantKey: "discord:user:alice",
participantLabel: "Alice",
});
writeBindings<TestDiscordState>(bindingsPath, {
"discord:user:alice": {
channelId: thread.channelId,
isDM: thread.isDM,
participantKey: "discord:user:alice",
participantLabel: "Alice",
serializedThread: JSON.stringify(thread.toJSON()),
sessionId: "session-alice",
state: {
sessionId: "session-alice",
participantKey: "discord:user:alice",
participantLabel: "Alice",
},
updatedAt: "2026-05-26T00:00:00.000Z",
},
});
await __test__.persistDiscordThreadContext({
thread,
bindingsPath,
baseStartRequest: {
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
systemPrompt: "system",
provider: "cline",
model: "test-model",
mode: "act",
},
message: {
raw: {
author: {
id: "bob",
username: "bob",
global_name: "Bob",
},
},
},
errorLabel: "Discord",
});
const bob =
readBindings<TestDiscordState>(bindingsPath)["discord:user:bob"];
expect(bob?.state?.participantKey).toBe("discord:user:bob");
expect(bob?.state?.participantLabel).toBe("Bob");
expect(bob?.state?.sessionId).toBeUndefined();
expect(
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
?.sessionId,
).toBe("session-alice");
});
it("adds Discord author context to runtime turns", () => {
expect(
__test__.formatDiscordRuntimeText(
"Heyo",
{
key: "discord:user:850213762576810065",
label: "Alice Example",
},
{
ownerUserId: "850213762576810065",
isDirectMention: false,
isSubscribedThreadMessage: true,
},
),
).toContain("authorId: 850213762576810065");
expect(
__test__.formatDiscordRuntimeText(
"Heyo",
{
key: "discord:user:850213762576810065",
label: "Alice Example",
},
{ ownerUserId: "850213762576810065" },
),
).toContain("isOwner: true");
expect(
__test__.formatDiscordRuntimeText(
"Heyo",
{
key: "discord:user:850213762576810065",
label: "Alice Example",
},
{
isDirectMention: false,
isSubscribedThreadMessage: true,
},
),
).toContain("isDirectMention: false");
expect(
__test__.formatDiscordRuntimeText(
"Heyo",
{
key: "discord:user:850213762576810065",
label: "Alice Example",
},
{
isDirectMention: false,
isSubscribedThreadMessage: true,
},
),
).toContain("isSubscribedThreadMessage: true");
});
it("instructs Discord agents to use /idle for unrelated subscribed thread messages", () => {
expect(__test__.DISCORD_SYSTEM_RULES).toContain("reply exactly /idle");
expect(__test__.DISCORD_SYSTEM_RULES).toContain("isDirectMention is false");
});
it("resolves outbound Discord mention names to user mention ids", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
expect(String(url)).toContain(
"/guilds/guild-123/members/search?query=cline-test-bot&limit=10",
);
return new Response(
JSON.stringify([
{
nick: "cline-test-bot",
user: {
id: "1509620637721821224",
username: "clinetestbot",
bot: true,
},
},
]),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(
__test__.resolveDiscordOutboundMentions({
botToken: "token",
threadId: "discord:guild-123:channel-123:thread-123",
text: "@cline-test-bot how is your day?",
}),
).resolves.toBe("<@1509620637721821224> how is your day?");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("repairs adapter-split hyphenated Discord mention names before resolving", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
expect(String(url)).toContain("query=cline-test-bot");
return new Response(
JSON.stringify([
{
nick: "cline-test-bot",
user: {
id: "1509620637721821224",
username: "clinetestbot",
bot: true,
},
},
]),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(
__test__.resolveDiscordOutboundMentions({
botToken: "token",
threadId: "discord:guild-123:channel-123:thread-123",
text: "<@cline>-test-bot how is your day?",
}),
).resolves.toBe("<@1509620637721821224> how is your day?");
});
it("does not resolve outbound mentions from non-exact Discord member search results", async () => {
const fetchMock = vi.fn(async () => {
return new Response(
JSON.stringify([
{
nick: "team-alice-bot",
user: {
id: "wrong-user",
username: "team-alice-bot",
bot: true,
},
},
]),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(
__test__.resolveDiscordOutboundMentions({
botToken: "token",
threadId: "discord:guild-123:channel-123:thread-123",
text: "@alice can you check this?",
}),
).resolves.toBe("@alice can you check this?");
});
it("normalizes forwarded bot-role mentions as Discord mentions", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
expect(String(url)).toContain("/guilds/guild-role-test/members/app-123");
return new Response(JSON.stringify({ roles: ["role-123"] }), {
status: 200,
});
});
vi.stubGlobal("fetch", fetchMock);
const request = new Request("https://example.test/api/webhooks/discord", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "GATEWAY_MESSAGE_CREATE",
data: {
id: "message-1",
guild_id: "guild-role-test",
channel_id: "channel-1",
content: "<@&role-123> hello",
mention_roles: ["role-123"],
mentions: [],
author: {
id: "user-1",
username: "alice",
bot: false,
},
},
}),
});
const normalized = await __test__.normalizeDiscordForwardedGatewayRequest({
request,
botToken: "token",
applicationId: "app-123",
});
const event = (await normalized.json()) as {
data: { is_mention?: boolean };
};
expect(event.data.is_mention).toBe(true);
});
it("retries bot role lookups after transient Discord API failures", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("temporary", { status: 500 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ roles: ["role-123"] }), {
status: 200,
}),
);
vi.stubGlobal("fetch", fetchMock);
const buildRequest = () =>
new Request("https://example.test/api/webhooks/discord", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "GATEWAY_MESSAGE_CREATE",
data: {
id: "message-1",
guild_id: "guild-retry-test",
channel_id: "channel-1",
content: "<@&role-123> hello",
mention_roles: ["role-123"],
mentions: [],
author: {
id: "user-1",
username: "alice",
bot: false,
},
},
}),
});
const failed = await __test__.normalizeDiscordForwardedGatewayRequest({
request: buildRequest(),
botToken: "token",
applicationId: "app-retry",
});
const failedEvent = (await failed.json()) as {
data: { is_mention?: boolean };
};
expect(failedEvent.data.is_mention).toBeUndefined();
const retried = await __test__.normalizeDiscordForwardedGatewayRequest({
request: buildRequest(),
botToken: "token",
applicationId: "app-retry",
});
const retriedEvent = (await retried.json()) as {
data: { is_mention?: boolean };
};
expect(retriedEvent.data.is_mention).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("restores persisted thread subscriptions once on startup", async () => {
const dir = await mkdtemp(join(tmpdir(), "discord-bindings-"));
const bindingsPath = join(dir, "threads.json");
const subscribe = vi.fn(async () => undefined);
const threads = new Map([
[
"thread-1",
{
id: "thread-1",
subscribe,
},
],
]);
const bot = {
reviver: () => (_key: string, value: unknown) => {
if (
value &&
typeof value === "object" &&
(value as { _type?: string })._type === "chat:Thread"
) {
return threads.get((value as { id: string }).id) ?? value;
}
return value;
},
};
const logger = {
core: { log: vi.fn() },
} as unknown as Parameters<
typeof __test__.restoreDiscordThreadSubscriptions
>[0]["logger"];
writeFileSync(
bindingsPath,
JSON.stringify({
"discord:user:1": {
channelId: "discord:g:c",
isDM: false,
participantKey: "discord:user:1",
serializedThread: JSON.stringify({
_type: "chat:Thread",
id: "thread-1",
}),
updatedAt: "2026-05-26T00:00:00.000Z",
},
duplicate: {
channelId: "discord:g:c",
isDM: false,
serializedThread: JSON.stringify({
_type: "chat:Thread",
id: "thread-1",
}),
updatedAt: "2026-05-26T00:00:00.000Z",
},
}),
);
const restored = await __test__.restoreDiscordThreadSubscriptions({
bot,
bindingsPath,
logger,
});
expect(restored).toBe(1);
expect(subscribe).toHaveBeenCalledTimes(1);
expect(logger.core.log).not.toHaveBeenCalled();
});
});
+662 -56
View File
@@ -39,6 +39,7 @@ import {
} from "../runtime-turn";
import {
buildConnectorStartRequest,
readSessionMessageCount,
readSessionReplyText,
stopConnectorSessions,
} from "../session-runtime";
@@ -51,6 +52,7 @@ import {
findBindingForParticipantKey,
findBindingForThread,
loadThreadState,
mergeThreadState,
persistMergedThreadState,
readBindings,
} from "../thread-bindings";
@@ -67,13 +69,42 @@ import {
const DISCORD_SYSTEM_RULES = getConnectorSystemRules(
"Discord",
"You can respond in Discord threads, channels, and DMs, and you can use tools according to the user's requests and your capabilities.",
[
"You can respond in Discord threads, channels, and DMs, and you can use tools according to the user's requests and your capabilities.",
"When asked to mention a Discord user or bot by name, write the mention as @display-name or @username. The connector resolves unique guild names to Discord mention IDs before sending. Do not ask the user for a Discord ID unless the name cannot be resolved.",
"Discord subscribed thread messages may arrive even when they are not addressed to you. Check <discord_message_context>: when isDirectMention is false and the message is part of another user or bot conversation that does not require your action, reply exactly /idle and nothing else. The connector treats /idle as a private no-op and will not post it to Discord.",
].join("\n"),
);
const DISCORD_FIRST_CONTACT_MESSAGE = getConnectorFirstContactMessage();
const DISCORD_GATEWAY_DURATION_MS = 1_800_000_000;
type DiscordThreadState = ConnectorThreadState;
type DiscordParticipant = { key: string; label?: string };
type DiscordMessageIdentity = { author?: unknown; raw?: unknown };
type DiscordThreadIdParts = {
guildId?: string;
channelId?: string;
threadId?: string;
};
type DiscordForwardedGatewayEvent = {
type?: string;
data?: unknown;
};
type DiscordGuildMemberResponse = {
roles?: unknown;
};
type DiscordGuildMember = {
nick?: string;
user?: {
id?: string;
username?: string;
global_name?: string | null;
bot?: boolean;
};
};
const botGuildRoleCache = new Map<string, Promise<Set<string>>>();
function truncateText(value: string, maxLength = 160): string {
return truncateConnectorText(value, maxLength);
@@ -108,6 +139,23 @@ async function buildDiscordStartRequest(
});
}
async function createDiscordEmptyRuntimeReplyResolver(input: {
client: HubSessionClient;
sessionId: string;
}): Promise<(() => Promise<string | undefined>) | undefined> {
const minMessageIndex = await readSessionMessageCount(
input.client,
input.sessionId,
);
if (minMessageIndex === undefined) {
return async () => undefined;
}
return () =>
readSessionReplyText(input.client, input.sessionId, {
minMessageIndex,
});
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object"
? (value as Record<string, unknown>)
@@ -128,57 +176,453 @@ function readIdentifier(value: unknown): string | undefined {
return undefined;
}
function resolveDiscordParticipant(
rawMessage: unknown,
): { key: string; label?: string } | undefined {
const raw = asRecord(rawMessage);
const data = asRecord(raw?.data) ?? raw;
const member = asRecord(data?.member);
const author =
asRecord(data?.author) ?? asRecord(member?.user) ?? asRecord(data?.user);
const userId = readIdentifier(author?.id);
const username = readString(author?.username);
const globalName =
readString(author?.global_name) || readString(author?.displayName);
const label = globalName || username || userId;
function readStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function normalizeDiscordLookupName(value: string | undefined): string {
return (value ?? "").trim().replace(/^@+/, "").toLowerCase();
}
function resolveDiscordParticipantFromAuthor(
author: unknown,
): DiscordParticipant | undefined {
const record = asRecord(author);
const userId =
readIdentifier(record?.userId) ||
readIdentifier(record?.id) ||
readIdentifier(record?.user_id);
const username =
readString(record?.userName) ||
readString(record?.username) ||
readString(record?.name);
const label =
readString(record?.fullName) ||
readString(record?.global_name) ||
readString(record?.displayName) ||
readString(record?.display_name) ||
username ||
userId;
if (!userId) {
return undefined;
}
return { key: `discord:user:${userId}`, label };
}
function resolveDiscordParticipant(
rawMessage: unknown,
messageAuthor?: unknown,
): DiscordParticipant | undefined {
const normalized = resolveDiscordParticipantFromAuthor(messageAuthor);
if (normalized) {
return normalized;
}
const raw = asRecord(rawMessage);
const data = asRecord(raw?.data) ?? raw;
const candidates = [
asRecord(raw?.author),
asRecord(asRecord(raw?.member)?.user),
asRecord(raw?.user),
asRecord(data?.author),
asRecord(asRecord(data?.member)?.user),
asRecord(data?.user),
asRecord(asRecord(raw?.message)?.author),
asRecord(asRecord(data?.message)?.author),
];
for (const candidate of candidates) {
const participant = resolveDiscordParticipantFromAuthor(candidate);
if (participant) {
return participant;
}
}
return undefined;
}
function formatDiscordRuntimeText(
text: string,
participant: DiscordParticipant | undefined,
options?: {
ownerUserId?: string;
isDirectMention?: boolean;
isSubscribedThreadMessage?: boolean;
},
): string {
if (!participant) {
return text;
}
const authorId = participant.key.replace(/^discord:user:/, "");
return [
"<discord_message_context>",
`authorId: ${authorId}`,
...(participant.label ? [`authorLabel: ${participant.label}`] : []),
`participantKey: ${participant.key}`,
...(options?.isDirectMention === undefined
? []
: [`isDirectMention: ${options.isDirectMention ? "true" : "false"}`]),
...(options?.isSubscribedThreadMessage === undefined
? []
: [
`isSubscribedThreadMessage: ${options.isSubscribedThreadMessage ? "true" : "false"}`,
]),
...(options?.ownerUserId && options.ownerUserId === authorId
? ["isOwner: true"]
: []),
"</discord_message_context>",
"",
text,
].join("\n");
}
function decodeDiscordThreadId(threadId: string): DiscordThreadIdParts {
const parts = threadId.split(":");
if (parts.length < 3 || parts[0] !== "discord") {
return {};
}
return {
guildId: parts[1],
channelId: parts[2],
threadId: parts[3],
};
}
async function readDiscordBotGuildRoleIds(input: {
botToken: string;
guildId: string;
applicationId: string;
}): Promise<Set<string>> {
const cacheKey = `${input.guildId}:${input.applicationId}`;
const cached = botGuildRoleCache.get(cacheKey);
if (cached) {
return cached;
}
const pending = fetchDiscordJson({
botToken: input.botToken,
path: `/guilds/${encodeURIComponent(input.guildId)}/members/${encodeURIComponent(input.applicationId)}`,
}).then(
(value) => {
const member = value as DiscordGuildMemberResponse;
return new Set(readStringArray(member.roles));
},
() => {
botGuildRoleCache.delete(cacheKey);
return new Set<string>();
},
);
botGuildRoleCache.set(cacheKey, pending);
return pending;
}
async function normalizeDiscordForwardedGatewayRequest(input: {
request: Request;
botToken: string;
applicationId: string;
}): Promise<Request> {
let event: DiscordForwardedGatewayEvent | undefined;
try {
event = (await input.request
.clone()
.json()) as DiscordForwardedGatewayEvent;
} catch {
return input.request;
}
if (event?.type !== "GATEWAY_MESSAGE_CREATE") {
return input.request;
}
const data = asRecord(event.data);
const guildId = readIdentifier(data?.guild_id);
const mentionRoleIds = readStringArray(data?.mention_roles);
if (!guildId || mentionRoleIds.length === 0 || data?.is_mention === true) {
return input.request;
}
const botRoleIds = await readDiscordBotGuildRoleIds({
botToken: input.botToken,
guildId,
applicationId: input.applicationId,
});
const mentionsBotRole = mentionRoleIds.some((roleId) =>
botRoleIds.has(roleId),
);
if (!mentionsBotRole) {
return input.request;
}
const headers = new Headers(input.request.headers);
headers.set("Content-Type", "application/json");
return new Request(input.request.url, {
method: input.request.method,
headers,
body: JSON.stringify({
...event,
data: {
...data,
is_mention: true,
},
}),
});
}
async function logDiscordForwardedGatewayMessage(input: {
request: Request;
logger: ConsoleLogger;
applicationId: string;
}): Promise<void> {
let event: DiscordForwardedGatewayEvent | undefined;
try {
event = (await input.request
.clone()
.json()) as DiscordForwardedGatewayEvent;
} catch {
return;
}
if (event?.type !== "GATEWAY_MESSAGE_CREATE") {
return;
}
const data = asRecord(event.data);
const author = asRecord(data?.author);
const mentions = Array.isArray(data?.mentions)
? data.mentions
.map((mention) => readIdentifier(asRecord(mention)?.id))
.filter(Boolean)
: [];
const mentionRoleIds = readStringArray(data?.mention_roles);
input.logger.info("Discord forwarded Gateway message received", {
channelId: readIdentifier(data?.channel_id),
guildId: readIdentifier(data?.guild_id) ?? null,
authorId: readIdentifier(author?.id),
authorName: readString(author?.username),
authorIsBot: author?.bot === true,
isMe: readIdentifier(author?.id) === input.applicationId,
isMentioned: mentions.includes(input.applicationId),
isRoleMentioned: data?.is_mention === true,
mentionIds: mentions,
mentionRoleIds,
content: readString(data?.content)?.slice(0, 100) ?? "",
});
}
async function fetchDiscordJson(input: {
botToken: string;
path: string;
method?: "GET" | "POST";
body?: unknown;
}): Promise<unknown> {
const response = await fetch(`https://discord.com/api/v10${input.path}`, {
method: input.method ?? "GET",
headers: {
Authorization: `Bot ${input.botToken}`,
...(input.body ? { "Content-Type": "application/json" } : {}),
"User-Agent": "Cline Discord Connector",
},
...(input.body ? { body: JSON.stringify(input.body) } : {}),
});
if (!response.ok) {
throw new Error(`Discord API ${response.status}: ${await response.text()}`);
}
return response.json();
}
async function searchDiscordGuildMembers(input: {
botToken: string;
guildId: string;
query: string;
}): Promise<DiscordGuildMember[]> {
const query = normalizeDiscordLookupName(input.query);
if (!query || input.guildId === "@me") {
return [];
}
const parsed = await fetchDiscordJson({
botToken: input.botToken,
path: `/guilds/${encodeURIComponent(input.guildId)}/members/search?query=${encodeURIComponent(query)}&limit=10`,
});
return Array.isArray(parsed) ? (parsed as DiscordGuildMember[]) : [];
}
function pickDiscordMemberByName(
members: DiscordGuildMember[],
query: string,
): DiscordGuildMember | undefined {
const normalizedQuery = normalizeDiscordLookupName(query);
const exact = members.filter((member) => {
const user = member.user;
return [member.nick, user?.username, user?.global_name ?? undefined].some(
(name) => normalizeDiscordLookupName(name) === normalizedQuery,
);
});
if (exact.length === 1) {
return exact[0];
}
return undefined;
}
async function resolveDiscordMentionName(input: {
botToken: string;
guildId: string | undefined;
name: string;
}): Promise<string | undefined> {
if (!input.guildId || input.guildId === "@me") {
return undefined;
}
const members = await searchDiscordGuildMembers({
botToken: input.botToken,
guildId: input.guildId,
query: input.name,
}).catch(() => []);
const match = pickDiscordMemberByName(members, input.name);
const id = readIdentifier(match?.user?.id);
return id ? `<@${id}>` : undefined;
}
async function resolveDiscordOutboundMentions(input: {
botToken: string;
threadId: string;
text: string;
}): Promise<string> {
const { guildId } = decodeDiscordThreadId(input.threadId);
if (!guildId || guildId === "@me" || !input.text.includes("@")) {
return input.text;
}
const mentionPattern =
/(^|[\s([{])(?:@([A-Za-z0-9_.-]{2,64})|<@([A-Za-z0-9_]{2,64})>((?:-[A-Za-z0-9_.]+)+))(?=$|[\s,.;:!?}\])])/g;
const replacements = new Map<string, string>();
for (const match of input.text.matchAll(mentionPattern)) {
const name = match[2] ?? `${match[3] ?? ""}${match[4] ?? ""}`;
if (!name || /^\d{15,25}$/.test(name) || replacements.has(name)) {
continue;
}
const mention = await resolveDiscordMentionName({
botToken: input.botToken,
guildId,
name,
});
if (mention) {
replacements.set(name, mention);
}
}
if (replacements.size === 0) {
return input.text.replace(
mentionPattern,
(full, prefix, rawName, base, suffix) => {
const name = rawName ?? `${base ?? ""}${suffix ?? ""}`;
return name ? `${prefix}@${name}` : full;
},
);
}
return input.text.replace(
mentionPattern,
(_full, prefix, rawName, base, suffix) => {
const name = rawName ?? `${base ?? ""}${suffix ?? ""}`;
const replacement = replacements.get(name);
return replacement ? `${prefix}${replacement}` : `${prefix}@${name}`;
},
);
}
async function postDiscordResolvedText(input: {
botToken: string;
thread: Thread<DiscordThreadState>;
text: string;
}): Promise<void> {
const resolvedText = await resolveDiscordOutboundMentions({
botToken: input.botToken,
threadId: input.thread.id,
text: input.text,
});
const { channelId, threadId } = decodeDiscordThreadId(input.thread.id);
const targetChannelId = threadId || channelId;
if (!targetChannelId) {
await input.thread.post(resolvedText);
return;
}
await fetchDiscordJson({
botToken: input.botToken,
path: `/channels/${encodeURIComponent(targetChannelId)}/messages`,
method: "POST",
body: {
content: resolvedText.slice(0, 2000),
allowed_mentions: { parse: ["users"] },
},
});
}
function resolveParticipantState(input: {
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
participant: DiscordParticipant;
}): DiscordThreadState {
const existing = findBindingForParticipantKey(
readBindings<DiscordThreadState>(input.bindingsPath),
input.participant.key,
)?.binding.state;
return {
...mergeThreadState<DiscordThreadState>(
undefined,
existing,
input.baseStartRequest,
),
participantKey: input.participant.key,
participantLabel: input.participant.label,
};
}
function resolveCurrentStateWithParticipant(input: {
currentState: DiscordThreadState;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
participant: DiscordParticipant;
}): DiscordThreadState {
if (input.currentState.participantKey === input.participant.key) {
return {
...input.currentState,
participantLabel: input.participant.label,
};
}
return resolveParticipantState({
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
participant: input.participant,
});
}
async function persistDiscordThreadContext(input: {
thread: Thread<DiscordThreadState>;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
rawMessage: unknown;
message: DiscordMessageIdentity;
errorLabel: string;
}): Promise<void> {
const participant = resolveDiscordParticipant(input.rawMessage);
}): Promise<DiscordParticipant | undefined> {
const participant = resolveDiscordParticipant(
input.message.raw,
input.message.author,
);
if (!participant) {
return;
return undefined;
}
const currentState = await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
);
const nextState = resolveCurrentStateWithParticipant({
currentState,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
participant,
});
if (
currentState.participantKey === participant.key &&
currentState.participantLabel === participant.label
currentState.participantKey === nextState.participantKey &&
currentState.participantLabel === nextState.participantLabel &&
currentState.sessionId === nextState.sessionId
) {
return;
return participant;
}
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...currentState,
participantKey: participant.key,
participantLabel: participant.label,
},
nextState,
input.errorLabel,
);
return participant;
}
async function deliverScheduledResult(input: {
@@ -240,6 +684,50 @@ async function deliverScheduledResult(input: {
await thread.post(body);
}
function isRestorableThread(
value: unknown,
): value is Thread<DiscordThreadState> & { subscribe(): Promise<void> } {
return (
Boolean(value) &&
typeof value === "object" &&
typeof (value as Thread<DiscordThreadState>).id === "string" &&
typeof (value as Thread<DiscordThreadState>).subscribe === "function"
);
}
async function restoreDiscordThreadSubscriptions(input: {
bot: Pick<Chat, "reviver">;
bindingsPath: string;
logger: ReturnType<typeof createCliLoggerAdapter>;
}): Promise<number> {
const bindings = readBindings<DiscordThreadState>(input.bindingsPath);
const restoredThreadIds = new Set<string>();
for (const binding of Object.values(bindings)) {
if (!binding.serializedThread?.trim()) {
continue;
}
try {
const thread = JSON.parse(
binding.serializedThread,
input.bot.reviver(),
) as unknown;
if (!isRestorableThread(thread) || restoredThreadIds.has(thread.id)) {
continue;
}
await thread.subscribe();
restoredThreadIds.add(thread.id);
} catch (error) {
input.logger.core.log("Failed to restore Discord thread subscription", {
severity: "warn",
error: error instanceof Error ? error.message : String(error),
channelId: binding.channelId,
participantKey: binding.participantKey,
});
}
}
return restoredThreadIds.size;
}
class DiscordConnector extends ConnectorBase<
ConnectDiscordOptions,
DiscordConnectorState
@@ -257,8 +745,15 @@ class DiscordConnector extends ConnectorBase<
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Discord bot username label")
.option("--application-id <id>", "Discord application id")
.option("--app-id <id>", "Alias for --application-id")
.option("--bot-token <token>", "Discord bot token")
.option("--token <token>", "Alias for --bot-token")
.option("--public-key <key>", "Discord application public key")
.option(
"--owner-user-id <id>",
"Discord user id that should be marked as connector owner",
)
.option("--ignore-bot-authors", "Ignore messages from other Discord bots")
.option(
"--mention-role-ids <ids>",
"Comma-separated role IDs that should trigger mention handlers",
@@ -294,6 +789,8 @@ class DiscordConnector extends ConnectorBase<
" DISCORD_APPLICATION_ID Discord application id",
" DISCORD_BOT_TOKEN Discord bot token",
" DISCORD_PUBLIC_KEY Discord application public key",
" DISCORD_OWNER_USER_ID Optional connector owner user id",
" DISCORD_IGNORE_BOT_AUTHORS Set to 1 to ignore messages from other bots",
" DISCORD_MENTION_ROLE_IDS Optional comma-separated role ids",
].join("\n"),
);
@@ -303,8 +800,12 @@ class DiscordConnector extends ConnectorBase<
const opts = command.opts<{
userName?: string;
applicationId?: string;
appId?: string;
botToken?: string;
token?: string;
publicKey?: string;
ownerUserId?: string;
ignoreBotAuthors?: boolean;
mentionRoleIds?: string;
cwd?: string;
model?: string;
@@ -339,12 +840,25 @@ class DiscordConnector extends ConnectorBase<
"cline-discord",
applicationId:
opts.applicationId?.trim() ||
opts.appId?.trim() ||
process.env.DISCORD_APPLICATION_ID?.trim() ||
"",
botToken:
opts.botToken?.trim() || process.env.DISCORD_BOT_TOKEN?.trim() || "",
opts.botToken?.trim() ||
opts.token?.trim() ||
process.env.DISCORD_BOT_TOKEN?.trim() ||
"",
publicKey:
opts.publicKey?.trim() || process.env.DISCORD_PUBLIC_KEY?.trim() || "",
ownerUserId:
opts.ownerUserId?.trim() ||
process.env.DISCORD_OWNER_USER_ID?.trim() ||
undefined,
allowBotAuthors:
!opts.ignoreBotAuthors &&
!/^(1|true|yes)$/i.test(
process.env.DISCORD_IGNORE_BOT_AUTHORS?.trim() ?? "",
),
mentionRoleIds: mentionRoleIds.length > 0 ? mentionRoleIds : undefined,
cwd: opts.cwd || process.cwd(),
model: opts.model,
@@ -582,6 +1096,11 @@ class DiscordConnector extends ConnectorBase<
const handleTurn = async (
thread: Thread<DiscordThreadState>,
text: string,
context?: {
participant?: DiscordParticipant;
isDirectMention?: boolean;
isSubscribedThreadMessage?: boolean;
},
) => {
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
@@ -591,6 +1110,11 @@ class DiscordConnector extends ConnectorBase<
await handleConnectorUserTurn({
thread,
text,
runtimeText: formatDiscordRuntimeText(text, context?.participant, {
ownerUserId: options.ownerUserId,
isDirectMention: context?.isDirectMention,
isSubscribedThreadMessage: context?.isSubscribedThreadMessage,
}),
client,
pendingApprovals,
baseStartRequest: startRequest,
@@ -611,9 +1135,14 @@ class DiscordConnector extends ConnectorBase<
chatCommandHost,
activeTurns,
turnKey: queueKey,
createEmptyRuntimeReplyResolver:
createDiscordEmptyRuntimeReplyResolver,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
applicationId: options.applicationId,
...(options.ownerUserId
? { discordOwnerUserId: options.ownerUserId }
: {}),
discordThreadId: currentThread.id,
discordChannelId: currentThread.channelId,
...(currentState.participantKey
@@ -625,6 +1154,16 @@ class DiscordConnector extends ConnectorBase<
}),
reusedLogMessage: "Discord thread reusing RPC session",
startedLogMessage: "Discord thread started RPC session",
postFinalReply: async ({
thread: replyThread,
text: replyText,
}) => {
await postDiscordResolvedText({
botToken: options.botToken,
thread: replyThread,
text: replyText,
});
},
onMessageReceived: async (details) => {
await dispatchConnectorHook(
options.hookCommand,
@@ -692,12 +1231,22 @@ class DiscordConnector extends ConnectorBase<
};
bot.onNewMention(async (thread, message) => {
loggerAdapter.core.log("Discord mention handler invoked", {
transport: "discord",
threadId: thread.id,
channelId: thread.channelId,
authorId: message.author.userId,
authorName: message.author.userName,
authorIsBot: message.author.isBot,
isMention: message.isMention,
textPreview: truncateText(message.text),
});
await thread.subscribe();
await persistDiscordThreadContext({
const participant = await persistDiscordThreadContext({
thread,
bindingsPath,
baseStartRequest: startRequest,
rawMessage: message.raw,
message,
errorLabel: "Discord",
});
if (
@@ -712,15 +1261,29 @@ class DiscordConnector extends ConnectorBase<
) {
return;
}
await handleTurn(thread, message.text);
await handleTurn(thread, message.text, {
participant,
isDirectMention: message.isMention,
isSubscribedThreadMessage: false,
});
});
bot.onSubscribedMessage(async (thread, message) => {
await persistDiscordThreadContext({
loggerAdapter.core.log("Discord subscribed message handler invoked", {
transport: "discord",
threadId: thread.id,
channelId: thread.channelId,
authorId: message.author.userId,
authorName: message.author.userName,
authorIsBot: message.author.isBot,
isMention: message.isMention,
textPreview: truncateText(message.text),
});
const participant = await persistDiscordThreadContext({
thread,
bindingsPath,
baseStartRequest: startRequest,
rawMessage: message.raw,
message,
errorLabel: "Discord",
});
if (
@@ -735,7 +1298,11 @@ class DiscordConnector extends ConnectorBase<
) {
return;
}
await handleTurn(thread, message.text);
await handleTurn(thread, message.text, {
participant,
isDirectMention: message.isMention,
isSubscribedThreadMessage: true,
});
});
bot.onSlashCommand(async (event) => {
@@ -755,17 +1322,29 @@ class DiscordConnector extends ConnectorBase<
isSubscribedContext: true,
});
await thread.subscribe();
await persistDiscordThreadContext({
const participant = await persistDiscordThreadContext({
thread,
bindingsPath,
baseStartRequest: startRequest,
rawMessage: event.raw,
message: {
raw: event.raw,
author: event.user,
},
errorLabel: "Discord",
});
await handleTurn(thread, commandText);
await handleTurn(thread, commandText, {
participant,
isDirectMention: true,
isSubscribedThreadMessage: false,
});
});
await bot.initialize();
const restoredSubscriptionCount = await restoreDiscordThreadSubscriptions({
bot,
bindingsPath,
logger: loggerAdapter,
});
const stopTaskUpdateStream =
startConnectorTaskUpdateRelay<DiscordThreadState>({
client,
@@ -776,6 +1355,37 @@ class DiscordConnector extends ConnectorBase<
transport: "discord",
});
const webhookUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/discord`;
const server = await startConnectorWebhookServer({
host: options.host,
port: options.port,
routes: {
"/api/webhooks/discord": async (request) => {
const normalizedRequest =
await normalizeDiscordForwardedGatewayRequest({
request,
botToken: options.botToken,
applicationId: options.applicationId,
});
await logDiscordForwardedGatewayMessage({
request: normalizedRequest,
logger: consoleLogger,
applicationId: options.applicationId,
});
return discord.handleWebhook(normalizedRequest);
},
"/health": () => new Response("ok"),
"/": () =>
new Response(
[
"Discord connector is running.",
`Interactions endpoint: ${webhookUrl}`,
`Gateway mode: ${options.allowBotAuthors ? "forwarded WebSocket listener" : "direct WebSocket listener"}`,
].join("\n"),
),
},
});
let gatewayTask: Promise<unknown> | undefined;
const gatewayAbortController = new AbortController();
const gatewayStartResponse = await discord.startGatewayListener(
@@ -792,8 +1402,10 @@ class DiscordConnector extends ConnectorBase<
},
DISCORD_GATEWAY_DURATION_MS,
gatewayAbortController.signal,
options.allowBotAuthors ? webhookUrl : undefined,
);
if (!gatewayStartResponse.ok) {
await server.close();
stopTaskUpdateStream();
userInstructionService.stop();
client.close();
@@ -804,25 +1416,6 @@ class DiscordConnector extends ConnectorBase<
return 1;
}
const webhookUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/discord`;
const server = await startConnectorWebhookServer({
host: options.host,
port: options.port,
routes: {
"/api/webhooks/discord": async (request) =>
discord.handleWebhook(request),
"/health": () => new Response("ok"),
"/": () =>
new Response(
[
"Discord connector is running.",
`Interactions endpoint: ${webhookUrl}`,
"Gateway mode: direct WebSocket listener",
].join("\n"),
),
},
});
const stopEventStream = client.streamEvents(
{ clientId: `${clientId}-server-events` },
{
@@ -881,8 +1474,13 @@ class DiscordConnector extends ConnectorBase<
`[discord] configure Discord interactions endpoint: ${webhookUrl}`,
);
io.writeln(
"[discord] gateway listener started for mentions, replies, reactions, and DMs",
`[discord] gateway listener started for mentions, replies, reactions, and DMs${options.allowBotAuthors ? " (bot authors allowed)" : ""}`,
);
if (restoredSubscriptionCount > 0) {
io.writeln(
`[discord] restored ${restoredSubscriptionCount} thread subscription${restoredSubscriptionCount === 1 ? "" : "s"}`,
);
}
await stopPromise;
gatewayAbortController.abort();
@@ -901,10 +1499,18 @@ export const discordConnector: ConnectCommandDefinition =
new DiscordConnector();
export const __test__ = {
DISCORD_SYSTEM_RULES,
createDiscordEmptyRuntimeReplyResolver,
formatDiscordRuntimeText,
findBindingForThread: (
bindings: ConnectorBindingStore<DiscordThreadState>,
thread: Pick<Thread<DiscordThreadState>, "id" | "channelId" | "isDM"> & {
participantKey?: string;
},
) => findBindingForThread(bindings, thread),
persistDiscordThreadContext,
normalizeDiscordForwardedGatewayRequest,
resolveDiscordOutboundMentions,
resolveDiscordParticipant,
restoreDiscordThreadSubscriptions,
};
+64 -1
View File
@@ -1,7 +1,11 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { __test__, readSessionReplyText } from "./common";
import {
__test__,
readSessionMessageCount,
readSessionReplyText,
} from "./common";
describe("spawnDetachedConnector", () => {
it("preserves the connect subcommand when building detached connector args", () => {
@@ -107,4 +111,63 @@ describe("readSessionReplyText", () => {
readSessionReplyText(client as never, "session-1"),
).resolves.toBe("latest reply");
});
it("can restrict fallback replies to messages after a known boundary", async () => {
const client = {
readMessages: async () => [
{
role: "assistant",
content: [{ type: "text", text: "previous reply" }],
},
{
role: "user",
content: [{ type: "text", text: "next question" }],
},
{
role: "assistant",
content: [{ type: "text", text: "current reply" }],
},
],
};
await expect(
readSessionReplyText(client as never, "session-1", {
minMessageIndex: 1,
}),
).resolves.toBe("current reply");
});
it("does not read prior assistant replies before the boundary", async () => {
const client = {
readMessages: async () => [
{
role: "assistant",
content: [{ type: "text", text: "previous reply" }],
},
{
role: "user",
content: [{ type: "text", text: "next question" }],
},
],
};
await expect(
readSessionReplyText(client as never, "session-1", {
minMessageIndex: 1,
}),
).resolves.toBeUndefined();
});
it("reads the session message count through the hub session client", async () => {
const client = {
readMessages: async () => [
{ role: "user", content: "one" },
{ role: "assistant", content: "two" },
],
};
await expect(
readSessionMessageCount(client as never, "session-1"),
).resolves.toBe(2);
});
});
+17 -1
View File
@@ -327,10 +327,16 @@ function extractMessageReplyText(message: unknown): string | undefined {
export async function readSessionReplyText(
client: HubSessionClient,
sessionId: string,
options: { minMessageIndex?: number } = {},
): Promise<string | undefined> {
try {
const messages = await client.readMessages(sessionId);
for (let index = messages.length - 1; index >= 0; index -= 1) {
const minMessageIndex = Math.max(0, options.minMessageIndex ?? 0);
for (
let index = messages.length - 1;
index >= minMessageIndex;
index -= 1
) {
const text = extractMessageReplyText(messages[index]);
if (text) {
return text;
@@ -339,3 +345,13 @@ export async function readSessionReplyText(
} catch {}
return undefined;
}
export async function readSessionMessageCount(
client: HubSessionClient,
sessionId: string,
): Promise<number | undefined> {
try {
return (await client.readMessages(sessionId)).length;
} catch {}
return undefined;
}
@@ -81,7 +81,15 @@ function baseStartRequest(overrides: Partial<TestState> = {}) {
};
}
function createRuntimeClient(responseText: string) {
function createRuntimeClient(
responseText: string,
messages: unknown[] = [
{
role: "assistant",
content: [{ type: "text", text: responseText }],
},
],
) {
const startRuntimeSession = vi.fn(async () => ({ sessionId: "session-1" }));
const updateSession = vi.fn(async () => undefined);
const sendRuntimeSession = vi.fn(async () => ({
@@ -91,16 +99,19 @@ function createRuntimeClient(responseText: string) {
iterations: 1,
},
}));
const readMessages = vi.fn(async () => messages);
return {
client: {
startRuntimeSession,
updateSession,
sendRuntimeSession,
readMessages,
streamEvents: vi.fn(() => () => undefined),
},
startRuntimeSession,
updateSession,
sendRuntimeSession,
readMessages,
};
}
@@ -620,6 +631,198 @@ describe("handleConnectorUserTurn", () => {
expect(posts).not.toContainEqual({ raw: "**Formatted** reply" });
});
it("suppresses connector /idle control replies", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread({
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
});
const runtime = createRuntimeClient(" /idle\n");
const postFinalReply = vi.fn(async () => undefined);
await handleConnectorUserTurn({
thread: thread as never,
text: "other bot replied in the thread",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "discord",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Discord",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
postFinalReply,
});
expect(postFinalReply).not.toHaveBeenCalled();
expect(posts).toEqual([]);
});
it("posts adapter fallback replies when the runtime stream is empty", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread({
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
});
const runtime = createRuntimeClient("");
const resolveFallbackText = vi.fn(
async () => "Summary from adapter fallback.",
);
const createEmptyRuntimeReplyResolver = vi.fn(
async () => resolveFallbackText,
);
await handleConnectorUserTurn({
thread: thread as never,
text: "read README.md",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest({
enableTools: true,
autoApproveTools: true,
}) as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "discord",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Discord",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
createEmptyRuntimeReplyResolver,
});
expect(createEmptyRuntimeReplyResolver).toHaveBeenCalledWith({
client: runtime.client,
sessionId: "session-1",
});
expect(resolveFallbackText).toHaveBeenCalledTimes(1);
expect(runtime.readMessages).not.toHaveBeenCalled();
expect(posts.at(-1)).toBe("Summary from adapter fallback.");
});
it("does not post stale fallback replies when no current-turn reply exists", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread({
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
});
const runtime = createRuntimeClient("");
const resolveFallbackText = vi.fn(async () => undefined);
const createEmptyRuntimeReplyResolver = vi.fn(
async () => resolveFallbackText,
);
await expect(
handleConnectorUserTurn({
thread: thread as never,
text: "run ls /tmp",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest({
enableTools: true,
autoApproveTools: true,
}) as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "discord",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Discord",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
createEmptyRuntimeReplyResolver,
}),
).rejects.toThrow("Runtime completed without assistant reply text.");
expect(createEmptyRuntimeReplyResolver).toHaveBeenCalledWith({
client: runtime.client,
sessionId: "session-1",
});
expect(resolveFallbackText).toHaveBeenCalledTimes(1);
expect(runtime.readMessages).not.toHaveBeenCalled();
expect(posts).not.toContain("Previous reply.");
});
it("keeps Telegram empty-stream behavior from reading session history", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread({
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
});
const runtime = createRuntimeClient("", [
{
role: "assistant",
content: [{ type: "text", text: "Previous reply." }],
},
]);
await handleConnectorUserTurn({
thread: thread as never,
text: "run ls /tmp",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest({
enableTools: true,
autoApproveTools: true,
}) as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "telegram",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Telegram",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
});
expect(runtime.readMessages).not.toHaveBeenCalled();
expect(posts.at(-1)).toEqual({ raw: " " });
});
it("steers active Telegram turns without the hub command timeout", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
@@ -666,4 +869,55 @@ describe("handleConnectorUserTurn", () => {
);
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
});
it("steers when the same session is active under a different turn key", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread({
sessionId: "session-1",
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("unused");
const activeTurns = new Map([
["other-turn-key", { sessionId: "session-1" }],
]);
await handleConnectorUserTurn({
thread: thread as never,
text: "reply while another participant key is active",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "telegram",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Telegram",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
turnKey: "thread-1",
});
expect(runtime.startRuntimeSession).not.toHaveBeenCalled();
expect(runtime.sendRuntimeSession).toHaveBeenCalledWith(
"session-1",
expect.objectContaining({
delivery: "steer",
}),
{ timeoutMs: null },
);
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
});
});
+42 -4
View File
@@ -40,6 +40,16 @@ export type ActiveConnectorTurn = {
sessionId: string;
};
type EmptyRuntimeReplyResolver = () => Promise<string | undefined>;
type EmptyRuntimeReplyResolverFactory = (input: {
client: HubSessionClient;
sessionId: string;
}) =>
| Promise<EmptyRuntimeReplyResolver | undefined>
| EmptyRuntimeReplyResolver
| undefined;
function connectorTextPayload(
transport: string,
text: string,
@@ -87,8 +97,9 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
transport: string,
stream: AsyncIterable<string>,
postFinalReply?: (text: string) => Promise<void>,
resolveFallbackText?: () => Promise<string | undefined>,
): Promise<void> {
if (transport !== "telegram") {
if (transport !== "telegram" && !postFinalReply && !resolveFallbackText) {
await thread.post(stream);
return;
}
@@ -97,6 +108,15 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
for await (const chunk of stream) {
text += chunk;
}
if (!text.trim()) {
text = (await resolveFallbackText?.())?.trim() || "";
}
if (isConnectorIdleReply(text)) {
return;
}
if (resolveFallbackText && !text.trim()) {
throw new Error("Runtime completed without assistant reply text.");
}
if (postFinalReply) {
await postFinalReply(text);
return;
@@ -118,11 +138,16 @@ function applyForcedToolDisable<TState extends ConnectorThreadState>(
};
}
export function isConnectorIdleReply(text: string): boolean {
return text.trim().toLowerCase() === "/idle";
}
export async function handleConnectorUserTurn<
TState extends ConnectorThreadState,
>(input: {
thread: Thread<TState>;
text: string;
runtimeText?: string;
client: HubSessionClient;
pendingApprovals: Map<string, PendingConnectorApproval>;
baseStartRequest: ChatStartSessionRequest;
@@ -171,6 +196,7 @@ export async function handleConnectorUserTurn<
thread: Thread<TState>;
text: string;
}) => Promise<void>;
createEmptyRuntimeReplyResolver?: EmptyRuntimeReplyResolverFactory;
onReplyCompleted?: (result: {
sessionId: string;
threadId: string;
@@ -188,6 +214,7 @@ export async function handleConnectorUserTurn<
if (!resolvedInput) {
return;
}
const runtimeInput = input.runtimeText?.trim() || resolvedInput;
const initialState = await loadThreadState(
input.thread,
@@ -668,10 +695,16 @@ export async function handleConnectorUserTurn<
input.baseStartRequest,
effectiveCurrentState,
);
const activeTurn = input.activeTurns?.get(turnKey);
const activeTurn =
input.activeTurns?.get(turnKey) ??
(input.activeTurns && currentState.sessionId?.trim()
? Array.from(input.activeTurns.values()).find(
(turn) => turn.sessionId === currentState.sessionId?.trim(),
)
: undefined);
if (activeTurn?.sessionId?.trim()) {
const { prompt, userImages, userFiles } = await buildUserInputMessage(
resolvedInput,
runtimeInput,
input.userInstructionService,
);
await input.client.sendRuntimeSession(
@@ -711,7 +744,7 @@ export async function handleConnectorUserTurn<
startedLogMessage: input.startedLogMessage,
});
const { prompt, userImages, userFiles } = await buildUserInputMessage(
resolvedInput,
runtimeInput,
input.userInstructionService,
);
const request: ChatRunTurnRequest = {
@@ -719,6 +752,10 @@ export async function handleConnectorUserTurn<
prompt,
attachments: buildAttachments({ userImages, userFiles }),
};
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
client: input.client,
sessionId,
});
input.activeTurns?.set(turnKey, { sessionId });
await input.thread.startTyping();
@@ -781,6 +818,7 @@ export async function handleConnectorUserTurn<
},
}),
postFinalReply,
resolveFallbackText,
);
} finally {
input.pendingApprovals.delete(input.thread.id);
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getConnector, listConnectors } from "./registry";
describe("connector registry", () => {
it("registers the Discord connector", async () => {
expect(listConnectors().map((connector) => connector.name)).toContain(
"discord",
);
await expect(getConnector("discord")).resolves.toMatchObject({
name: "discord",
});
});
});
+9
View File
@@ -7,6 +7,15 @@ type ConnectorRegistryEntry = {
};
const registry = new Map<string, ConnectorRegistryEntry>([
[
"discord",
{
name: "discord",
description:
"Discord interactions and gateway bridge backed by RPC runtime sessions",
load: async () => (await import("./adapters/discord")).discordConnector,
},
],
[
"gchat",
{
@@ -67,6 +67,38 @@ describe("createConnectorRuntimeTurnStream", () => {
});
});
it("treats queued runtime turns as non-error completion", async () => {
const log = vi.fn();
const client = {
streamEvents: (_request: unknown, _callbacks: StreamHandlers) => {
return () => {};
},
sendRuntimeSession: vi.fn(async () => ({})),
};
const chunks: string[] = [];
for await (const chunk of createConnectorRuntimeTurnStream({
client: client as never,
sessionId: "session-1",
request: { config: {} as never, prompt: "hi" },
clientId: "client-1",
logger: { core: { log } } as unknown as CliLoggerAdapter,
transport: "discord",
conversationId: "thread-1",
})) {
chunks.push(chunk);
}
expect(chunks).toEqual([]);
expect(log).toHaveBeenCalledWith(
"Connector runtime turn queued",
expect.objectContaining({
transport: "discord",
sessionId: "session-1",
}),
);
});
it("surfaces normalized runtime failed errors", async () => {
let handlers: StreamHandlers | undefined;
+6 -1
View File
@@ -275,7 +275,12 @@ export function createConnectorRuntimeTurnStream(input: {
.sendRuntimeSession(input.sessionId, input.request, { timeoutMs: null })
.then(async (response) => {
if (!response.result) {
throw new Error("connector runtime turn unexpectedly queued");
input.logger.core.log("Connector runtime turn queued", {
transport: input.transport,
conversationId: input.conversationId,
sessionId: input.sessionId,
});
return;
}
if (failed) {
return;
@@ -20,6 +20,7 @@ import { resolveWorkspaceRoot } from "../utils/helpers";
import {
parseLocalRowMetadata,
parseRowMetadata,
readSessionMessageCount,
readSessionReplyText,
} from "./common";
import { dispatchConnectorHook } from "./hooks";
@@ -336,4 +337,4 @@ export async function stopConnectorSessions(input: {
return filtered.length;
}
export { readSessionReplyText };
export { readSessionMessageCount, readSessionReplyText };
@@ -27,6 +27,7 @@ import {
getDefaultAwsRegion,
type ProviderConfigValues,
resolveProviderConfigAwsRegion,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "../../utils/provider-config-values";
import { getProviderSection } from "../../utils/provider-sections";
@@ -316,6 +317,11 @@ const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
baseUrl: "Base URL",
awsRegion: "AWS Region",
awsProfile: "AWS Profile Name",
sapClientId: "Client ID",
sapClientSecret: "Client Secret",
sapTokenUrl: "Token URL",
sapResourceGroup: "Resource Group",
sapDeploymentId: "Deployment ID",
};
const DEFAULT_FIELD_PLACEHOLDERS: Partial<
@@ -325,6 +331,11 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
baseUrl: "",
awsRegion: "us-east-1",
awsProfile: "default",
sapClientId: "sb-...|xsuaa_std!b...",
sapClientSecret: "SAP AI Core client secret",
sapTokenUrl: "https://<subdomain>.authentication.sap.hana.ondemand.com",
sapResourceGroup: "default",
sapDeploymentId: "",
};
/** Render order for cycling focus with Tab. */
@@ -333,6 +344,11 @@ const FIELD_ORDER: ProviderConfigFieldKey[] = [
"baseUrl",
"apiKey",
"awsProfile",
"sapClientId",
"sapClientSecret",
"sapTokenUrl",
"sapResourceGroup",
"sapDeploymentId",
];
export type ProviderConfigInputFields = Partial<
@@ -391,6 +407,19 @@ export function ProviderConfigInputContent(
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
if (config.fields.awsProfile)
initial.awsProfile = existingSettings?.aws?.profile?.trim() ?? "";
if (config.fields.sapClientId)
initial.sapClientId = existingSettings?.sap?.clientId?.trim() ?? "";
if (config.fields.sapClientSecret)
initial.sapClientSecret =
existingSettings?.sap?.clientSecret?.trim() ?? "";
if (config.fields.sapTokenUrl)
initial.sapTokenUrl = existingSettings?.sap?.tokenUrl?.trim() ?? "";
if (config.fields.sapResourceGroup)
initial.sapResourceGroup =
existingSettings?.sap?.resourceGroup?.trim() ?? "default";
if (config.fields.sapDeploymentId)
initial.sapDeploymentId =
existingSettings?.sap?.deploymentId?.trim() ?? "";
return initial;
});
@@ -402,6 +431,12 @@ export function ProviderConfigInputContent(
const apiKey = values.apiKey?.trim();
const awsProfile = values.awsProfile?.trim();
const hasAwsFields = config.fields.awsRegion || config.fields.awsProfile;
const hasSapFields =
config.fields.sapClientId ||
config.fields.sapClientSecret ||
config.fields.sapTokenUrl ||
config.fields.sapResourceGroup ||
config.fields.sapDeploymentId;
saveLocalProviderSettings(providerSettingsManager, {
providerId,
apiKey: config.fields.apiKey ? apiKey : undefined,
@@ -413,6 +448,7 @@ export function ProviderConfigInputContent(
profile: apiKey ? undefined : awsProfile || undefined,
}
: undefined,
sap: hasSapFields ? resolveProviderConfigSap(values) : undefined,
});
resolve(true);
};
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
getDefaultAwsRegion,
resolveProviderConfigAwsRegion,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "./provider-config-values";
@@ -64,4 +65,22 @@ describe("provider config values", () => {
}),
).toBe("us-west-2");
});
it("resolves SAP AI Core field values into SAP settings", () => {
expect(
resolveProviderConfigSap({
sapClientId: " client ",
sapClientSecret: " secret ",
sapTokenUrl: " https://auth.example ",
sapResourceGroup: " default ",
sapDeploymentId: " deployment ",
}),
).toEqual({
clientId: "client",
clientSecret: "secret",
tokenUrl: "https://auth.example",
resourceGroup: "default",
deploymentId: "deployment",
});
});
});
@@ -20,6 +20,27 @@ export function resolveProviderConfigAwsRegion(
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
}
export function resolveProviderConfigSap(values: ProviderConfigValues):
| {
clientId?: string;
clientSecret?: string;
tokenUrl?: string;
resourceGroup?: string;
deploymentId?: string;
}
| undefined {
const sap = {
clientId: values.sapClientId?.trim() || undefined,
clientSecret: values.sapClientSecret?.trim() || undefined,
tokenUrl: values.sapTokenUrl?.trim() || undefined,
resourceGroup: values.sapResourceGroup?.trim() || undefined,
deploymentId: values.sapDeploymentId?.trim() || undefined,
};
return Object.values(sap).some((value) => value !== undefined)
? sap
: undefined;
}
export function updateProviderConfigValue(
previous: ProviderConfigValues,
field: ProviderConfigFieldKey,
@@ -28,13 +28,14 @@ import {
useSearchableList,
} from "../../components/searchable-list";
import { palette } from "../../palette";
import { getProviderSection } from "../../utils/provider-sections";
import {
getDefaultAwsRegion,
resolveProviderConfigAwsRegion,
updateProviderConfigValue,
type ProviderConfigValues,
resolveProviderConfigAwsRegion,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "../../utils/provider-config-values";
import { getProviderSection } from "../../utils/provider-sections";
import {
isOnboardingOAuthProviderId,
type OnboardingOAuthProviderId,
@@ -392,6 +393,24 @@ export function useOnboardingController(props: OnboardingControllerProps) {
if (config.fields.awsProfile) {
initialValues.awsProfile = existing?.aws?.profile?.trim() ?? "";
}
if (config.fields.sapClientId) {
initialValues.sapClientId = existing?.sap?.clientId?.trim() ?? "";
}
if (config.fields.sapClientSecret) {
initialValues.sapClientSecret =
existing?.sap?.clientSecret?.trim() ?? "";
}
if (config.fields.sapTokenUrl) {
initialValues.sapTokenUrl = existing?.sap?.tokenUrl?.trim() ?? "";
}
if (config.fields.sapResourceGroup) {
initialValues.sapResourceGroup =
existing?.sap?.resourceGroup?.trim() ?? "default";
}
if (config.fields.sapDeploymentId) {
initialValues.sapDeploymentId =
existing?.sap?.deploymentId?.trim() ?? "";
}
setByoValues(initialValues);
// Focus the first visible field
@@ -426,6 +445,12 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const apiKey = byoValues.apiKey?.trim();
const awsProfile = byoValues.awsProfile?.trim();
const hasAwsFields = byoFields.awsRegion || byoFields.awsProfile;
const hasSapFields =
byoFields.sapClientId ||
byoFields.sapClientSecret ||
byoFields.sapTokenUrl ||
byoFields.sapResourceGroup ||
byoFields.sapDeploymentId;
saveLocalProviderSettings(providerSettingsManager, {
providerId: activeProviderId,
@@ -438,6 +463,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
profile: apiKey ? undefined : awsProfile || undefined,
}
: undefined,
sap: hasSapFields ? resolveProviderConfigSap(byoValues) : undefined,
});
// Emit a single `user.provider_configured` event mirroring the
// `{ provider }` payload shape used by the auth funnel. The save above
@@ -6,4 +6,9 @@ export const FIELD_ORDER: ProviderConfigFieldKey[] = [
"baseUrl",
"apiKey",
"awsProfile",
"sapClientId",
"sapClientSecret",
"sapTokenUrl",
"sapResourceGroup",
"sapDeploymentId",
];
@@ -224,6 +224,11 @@ const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
baseUrl: "Base URL",
awsRegion: "AWS Region",
awsProfile: "AWS Profile Name",
sapClientId: "Client ID",
sapClientSecret: "Client Secret",
sapTokenUrl: "Token URL",
sapResourceGroup: "Resource Group",
sapDeploymentId: "Deployment ID",
};
const DEFAULT_FIELD_PLACEHOLDERS: Partial<
@@ -233,6 +238,11 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
baseUrl: "",
awsRegion: "us-east-1",
awsProfile: "default",
sapClientId: "sb-...|xsuaa_std!b...",
sapClientSecret: "SAP AI Core client secret",
sapTokenUrl: "https://<subdomain>.authentication.sap.hana.ondemand.com",
sapResourceGroup: "default",
sapDeploymentId: "",
};
export function OnboardingProviderConfigScreen(props: {
+10
View File
@@ -167,6 +167,16 @@ export function writeln(text = ""): void {
write(`${text}\n`);
}
export function writeDiagnostic(text: string): void {
try {
process.stderr.write(`${text}\n`);
} catch (error) {
if (!isBrokenPipeError(error)) {
throw error;
}
}
}
export function prepareTerminalForPostTuiOutput(): void {
if (currentOutputMode === "json" || !process.stdout.isTTY) {
return;
@@ -142,6 +142,7 @@ describe("provider readiness", () => {
expect(
isProviderSettingsUsable("sapaicore", {
provider: "sapaicore",
baseUrl: "https://api.ai.example.invalid",
sap: {
clientId: "client",
clientSecret: "secret",
@@ -149,5 +150,15 @@ describe("provider readiness", () => {
},
} satisfies ProviderSettings),
).toBe(true);
expect(
isProviderSettingsUsable("sapaicore", {
provider: "sapaicore",
sap: {
clientId: "client",
clientSecret: "secret",
tokenUrl: "https://example.com/token",
},
} satisfies ProviderSettings),
).toBe(false);
});
});
+5 -4
View File
@@ -45,7 +45,8 @@ function hasSapCredentials(settings: ProviderSettings): boolean {
return (
hasText(sap?.clientId) &&
hasText(sap?.clientSecret) &&
hasText(sap?.tokenUrl)
hasText(sap?.tokenUrl) &&
hasText(settings.baseUrl)
);
}
@@ -68,6 +69,9 @@ export function isProviderSettingsUsable(
hasAwsRegion(settings)
);
}
if (normalizedProviderId === "sapaicore") {
return hasSapCredentials(settings);
}
if (getPersistedProviderApiKey(normalizedProviderId, settings)) {
return true;
}
@@ -84,9 +88,6 @@ export function isProviderSettingsUsable(
if (normalizedProviderId === "azure") {
return hasAzureCredentials(settings);
}
if (normalizedProviderId === "sapaicore") {
return hasSapCredentials(settings);
}
if (!fields.fields.baseUrl) {
return false;
}
@@ -184,7 +184,10 @@ export const PLATFORMS: PlatformDef[] = [
label: "Public base URL",
placeholder: "https://example.com",
required: true,
help: ["Set this as the Interactions Endpoint URL"],
help: [
"Base URL for the connector",
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
],
},
],
},
+121 -2
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.11",
"version": "3.0.13",
"bin": {
"cline": "src/index.ts",
},
@@ -350,6 +350,7 @@
"@ai-sdk/provider": "^3.0.8",
"@aws-sdk/credential-providers": "^3.922.0",
"@cline/shared": "workspace:*",
"@jerome-benoit/sap-ai-provider": "4.6.9",
"@langfuse/otel": "^4.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-trace-node": "^2.6.1",
@@ -614,6 +615,10 @@
"@cline/vscode": ["@cline/vscode@workspace:apps/examples/vscode"],
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
"@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="],
"@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="],
@@ -810,6 +815,8 @@
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@jerome-benoit/sap-ai-provider": ["@jerome-benoit/sap-ai-provider@4.6.9", "", { "dependencies": { "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.26", "@sap-ai-sdk/foundation-models": "^2.10.0", "@sap-ai-sdk/orchestration": "^2.10.0", "zod": "^4.4.2" }, "peerDependencies": { "ai": "^5.0.0 || ^6.0.0" } }, "sha512-z+ayDBasD2/PB4iAO6Lp6myQ96KZYjN/gBX3WAisaFKcCeRIsJyzHcBQh26lDu0cpzAybRmI5XUrWzn3nRaqgA=="],
"@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="],
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
@@ -1186,6 +1193,30 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@sap-ai-sdk/ai-api": ["@sap-ai-sdk/ai-api@2.11.0", "", { "dependencies": { "@sap-ai-sdk/core": "^2.11.0", "@sap-cloud-sdk/connectivity": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0" } }, "sha512-KmBSIJfwSv7MlVFbNbZx9RQXT9P2hYvBQKPmAwURXc6Vh+UfNi1F2b45Nt8Yi90z+HFw0lsazgVN5wgav+qFGQ=="],
"@sap-ai-sdk/core": ["@sap-ai-sdk/core@2.11.0", "", { "dependencies": { "@sap-cloud-sdk/connectivity": "^4.7.0", "@sap-cloud-sdk/http-client": "^4.7.0", "@sap-cloud-sdk/openapi": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0" } }, "sha512-6xJ0X88vi/eDG159rDs4S/g8Y6Gq9/B4O21oimIPccSJQF7WIB2+2Z4lFKTSBem/5E3FfxqknIZ/5bPX+/6lHA=="],
"@sap-ai-sdk/foundation-models": ["@sap-ai-sdk/foundation-models@2.11.0", "", { "dependencies": { "@sap-ai-sdk/ai-api": "^2.11.0", "@sap-ai-sdk/core": "^2.11.0", "@sap-cloud-sdk/connectivity": "^4.7.0", "@sap-cloud-sdk/http-client": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0" } }, "sha512-AYRzR2+RA+9yCGzLymdUjcNPVDosXxHQrbFYNEcGicpYZu/Gy0WOc1GU/FCywfdqa0mGphSRpGCT+eduXLA1pw=="],
"@sap-ai-sdk/orchestration": ["@sap-ai-sdk/orchestration@2.11.0", "", { "dependencies": { "@sap-ai-sdk/ai-api": "^2.11.0", "@sap-ai-sdk/core": "^2.11.0", "@sap-ai-sdk/prompt-registry": "^2.11.0", "@sap-cloud-sdk/util": "^4.6.0", "yaml": "^2.9.0" } }, "sha512-UR/erV97rtuEUXPGgMeDPmr3Yn2DLF5duQcvd4BleZ4bOSorarIj6igEd8XnBnGpSvcMCIgs5B6J2Vvi7E3+Ag=="],
"@sap-ai-sdk/prompt-registry": ["@sap-ai-sdk/prompt-registry@2.11.0", "", { "dependencies": { "@sap-ai-sdk/core": "^2.11.0", "zod": "^4.4.3" } }, "sha512-1WrqGjwHltF/aOXoNDPYtvvaprQarPsKZUC0wSMeC6XZokoYvjvHNrltLm1IFygpjTr8yma45JJkKcwV1qzvDg=="],
"@sap-cloud-sdk/connectivity": ["@sap-cloud-sdk/connectivity@4.7.0", "", { "dependencies": { "@sap-cloud-sdk/resilience": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0", "@sap/xsenv": "^6.2.0", "@sap/xssec": "^4.13.0", "async-retry": "^1.3.3", "axios": "^1.15.0", "jks-js": "^1.1.6", "jsonwebtoken": "^9.0.3", "safe-stable-stringify": "^2.5.0" } }, "sha512-+EgdTpGi3ZomPuv/ab+bWQIxUfJvownW2MzdYSvX7hkEkmKJfod0O6ja2OqC+OJBLm1TE0JpbJ6AcCkqeZYuOg=="],
"@sap-cloud-sdk/http-client": ["@sap-cloud-sdk/http-client@4.7.0", "", { "dependencies": { "@sap-cloud-sdk/connectivity": "^4.7.0", "@sap-cloud-sdk/resilience": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0", "axios": "^1.15.0" } }, "sha512-7+S6ru7SrnyKA2MGimX09oix0EVwmPGcCAz0TRwFZVnglU+VTRmZ8QdcwcVTR26E9YhkR4OVl6EK7jb2Z0OxfQ=="],
"@sap-cloud-sdk/openapi": ["@sap-cloud-sdk/openapi@4.7.0", "", { "dependencies": { "@sap-cloud-sdk/connectivity": "^4.7.0", "@sap-cloud-sdk/http-client": "^4.7.0", "@sap-cloud-sdk/resilience": "^4.7.0", "@sap-cloud-sdk/util": "^4.7.0", "axios": "^1.15.0" } }, "sha512-OKTEGVScCRsfgL9Lk/bEKcGnyfq0GgruXuZfsxoMbTqbe2f9X/fgHgXIWQEkTuXQplkrSDiNFRyvl0n/ON9Ycg=="],
"@sap-cloud-sdk/resilience": ["@sap-cloud-sdk/resilience@4.7.0", "", { "dependencies": { "@sap-cloud-sdk/util": "^4.7.0", "async-retry": "^1.3.3", "axios": "^1.15.0", "opossum": "^9.0.0" } }, "sha512-L6PV49nNyZHnTNFbvaufadm+qOhaammXLXkDQmD7WIzMjhiYMqa/obK3NJoohe/kZ7q73jPB2vdLsAqopqTh0A=="],
"@sap-cloud-sdk/util": ["@sap-cloud-sdk/util@4.7.0", "", { "dependencies": { "axios": "^1.15.0", "logform": "^2.7.0", "voca": "^1.4.1", "winston": "^3.19.0", "winston-transport": "^4.9.0" } }, "sha512-nWJXMdM0Pcx/ipM2+5BvSciQKyCc0LAAO+acCOAQp65SA4HEQ2Odp9yxidY4ijW42TVp3fSMBvGwVjbWWx9Uew=="],
"@sap/xsenv": ["@sap/xsenv@6.2.1", "", { "dependencies": { "debug": "4.4.3", "node-cache": "^5.1.2", "verror": "1.10.1" } }, "sha512-R1p7VdD3N3jvdkL8av4vLqF+cTQihTz9mCqqF+oa9rVZvgLaCb4ODyZ1dln5/fBgg1OSuch0ESxu3AqZrXVknw=="],
"@sap/xssec": ["@sap/xssec@4.13.0", "", { "dependencies": { "debug": "^4.4.3", "jwt-decode": "^4" } }, "sha512-8e+bU+OyAIpAGXQanOopZa5YEK+yHKw84dhhihcCotF40MSNFbVHjQ4xM5hf4QndlqDGfXIuvXmoOMuDATa/gA=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
@@ -1246,6 +1277,8 @@
"@smithy/util-utf8": ["@smithy/util-utf8@4.3.4", "", { "dependencies": { "@smithy/core": "^3.24.4", "tslib": "^2.6.2" } }, "sha512-s8lfXcv+5C2GjBwGUBqFLgNmhyp9/n4TSKbOzKlIqJ/x0L/zwIxjNBC6DN4xUy59NvOrsiZI1t3tWi4ADUDyNw=="],
"@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@streamdown/cjk": ["@streamdown/cjk@1.0.3", "", { "dependencies": { "remark-cjk-friendly": "^2.0.1", "remark-cjk-friendly-gfm-strikethrough": "^2.0.1", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-WRg8HR/gHbBoTgsMd91OKFUClIoDcEFVofJvluvEAyjx3KpU0aGgD9tGDqHkHj14ShoMSkX0IYetWGegTcwIJw=="],
@@ -1470,6 +1503,8 @@
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@@ -1580,10 +1615,18 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
@@ -1686,16 +1729,22 @@
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
@@ -1716,6 +1765,8 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
@@ -1878,6 +1929,8 @@
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"enhanced-resolve": ["enhanced-resolve@5.22.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A=="],
@@ -1968,6 +2021,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
@@ -1994,6 +2049,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
@@ -2012,6 +2069,8 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
@@ -2224,6 +2283,8 @@
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"jks-js": ["jks-js@1.1.7", "", { "dependencies": { "node-forge": "^1.4.0", "node-int64": "^0.4.0", "node-rsa": "^1.1.1" } }, "sha512-BeiDRKsAi1NwEwgx2JB/9/0tar5BNGIv+foGm1G5GgiyR35s/iUnfd/BWqYd16mLDD8qTaAVBrIcOOuVqXJZNQ=="],
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
@@ -2258,10 +2319,14 @@
"jsonrepair": ["jsonrepair@3.14.0", "", { "bin": { "jsonrepair": "bin/cli.js" } }, "sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg=="],
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -2270,6 +2335,8 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -2312,14 +2379,30 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -2490,16 +2573,24 @@
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"node-cache": ["node-cache@5.1.2", "", { "dependencies": { "clone": "2.x" } }, "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"node-machine-id": ["node-machine-id@1.1.12", "", {}, "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ=="],
"node-pty": ["node-pty@1.2.0-beta.11", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ=="],
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
"node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -2518,6 +2609,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -2528,6 +2621,8 @@
"opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="],
"opossum": ["opossum@9.0.0", "", {}, "sha512-K76U0QkxOfUZamneQuzz+AP0fyfTJcCplZ2oZL93nxeupuJbN4s6uFNbmVCt4eWqqGqRnnowdFuBicJ1fLMVxw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
@@ -2686,7 +2781,7 @@
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
@@ -2826,6 +2921,8 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -2888,6 +2985,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
@@ -2922,6 +3021,8 @@
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
@@ -3006,6 +3107,8 @@
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
@@ -3018,6 +3121,8 @@
"vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="],
"voca": ["voca@1.4.1", "", {}, "sha512-NJC/BzESaHT1p4B5k4JykxedeltmNbau4cummStd4RjFojgq/kLew5TzYge9N2geeWyI2w8T30wUET5v+F7ZHA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
@@ -3030,6 +3135,10 @@
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
"winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"workerpool": ["workerpool@9.3.4", "", {}, "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg=="],
@@ -3256,6 +3365,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"async-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
@@ -3268,6 +3379,10 @@
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="],
"color-string/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
@@ -3378,6 +3493,8 @@
"react-jsx-parser/@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -3462,6 +3579,8 @@
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"color/color-convert/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
@@ -8,6 +8,13 @@ const ctx: AgentToolContext = {
iteration: 1,
};
function shellExecutable(value: string): string {
if (process.platform === "win32") {
return `& '${value.replaceAll("'", "''")}'`;
}
return `'${value.replaceAll("'", `'\\''`)}'`;
}
describe("createBashExecutor", () => {
it("runs a simple command and returns stdout", async () => {
const bash = createBashExecutor();
@@ -22,7 +29,7 @@ describe("createBashExecutor", () => {
it("includes stderr in combined output on success", async () => {
const bash = createBashExecutor({ combineOutput: true });
const cmd = `${process.execPath} -e "process.stdout.write('ok'); process.stderr.write('warn')"`;
const cmd = `${shellExecutable(process.execPath)} -e "process.stdout.write('ok'); process.stderr.write('warn')"`;
const output = await bash(cmd, process.cwd(), ctx);
expect(output).toContain("ok");
expect(output).toContain("[stderr]");
@@ -31,7 +38,7 @@ describe("createBashExecutor", () => {
it("excludes stderr when combineOutput is false", async () => {
const bash = createBashExecutor({ combineOutput: false });
const cmd = `${process.execPath} -e "process.stdout.write('ok'); process.stderr.write('warn')"`;
const cmd = `${shellExecutable(process.execPath)} -e "process.stdout.write('ok'); process.stderr.write('warn')"`;
const output = await bash(cmd, process.cwd(), ctx);
expect(output.trim()).toBe("ok");
});
@@ -46,7 +53,7 @@ describe("createBashExecutor", () => {
it("truncates output exceeding maxOutputBytes", async () => {
const bash = createBashExecutor({ maxOutputBytes: 10 });
const output = await bash(
`${process.execPath} -e "process.stdout.write('a'.repeat(100))"`,
`${shellExecutable(process.execPath)} -e "process.stdout.write('a'.repeat(100))"`,
process.cwd(),
ctx,
);
@@ -87,6 +87,7 @@ describe("ensureDetachedHubServer", () => {
const fetchMock = vi.fn(async () => ({ ok: true }));
beforeEach(() => {
delete process.env[CLINE_RUN_AS_HUB_DAEMON_ENV];
vi.stubGlobal("fetch", fetchMock);
});
+17 -7
View File
@@ -59,13 +59,23 @@ function isCompatibleHubRecord(record: HubServerDiscoveryRecord): boolean {
return !!recordBuildId && recordBuildId === resolveHubBuildId();
}
async function safeProbeHubServer(
url: string,
): Promise<HubServerDiscoveryRecord | undefined> {
try {
return await probeHubServer(url);
} catch {
return undefined;
}
}
async function waitForHubToRetire(
url: string,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const healthy = await probeHubServer(url).catch(() => undefined);
const healthy = await safeProbeHubServer(url);
if (!healthy?.url) {
return true;
}
@@ -207,7 +217,7 @@ export function prewarmDetachedHubServer(
void readHubDiscovery(owner.discoveryPath)
.then(async (discovered) => {
if (discovered?.url) {
const healthy = await probeHubServer(discovered.url);
const healthy = await safeProbeHubServer(discovered.url);
if (
healthy?.url &&
isCompatibleHubRecord(healthy) &&
@@ -226,7 +236,7 @@ export function prewarmDetachedHubServer(
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
}
}
const expected = await probeHubServer(expectedUrl);
const expected = await safeProbeHubServer(expectedUrl);
if (expected?.url) {
await retireIncompatibleHub(expected, owner.discoveryPath);
}
@@ -276,7 +286,7 @@ export async function ensureDetachedHubServer(
};
const discovered = await readHubDiscovery(owner.discoveryPath);
if (discovered?.url) {
const healthy = await probeHubServer(discovered.url);
const healthy = await safeProbeHubServer(discovered.url);
if (
healthy?.url &&
isCompatibleHubRecord(healthy) &&
@@ -298,7 +308,7 @@ export async function ensureDetachedHubServer(
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
}
}
const expected = await probeHubServer(expectedUrl);
const expected = await safeProbeHubServer(expectedUrl);
if (expected?.url) {
await retireIncompatibleHub(expected, owner.discoveryPath);
}
@@ -311,7 +321,7 @@ export async function ensureDetachedHubServer(
while (Date.now() < deadline) {
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
if (nextDiscovery?.url) {
const healthy = await probeHubServer(nextDiscovery.url);
const healthy = await safeProbeHubServer(nextDiscovery.url);
if (
healthy?.url &&
isCompatibleHubRecord(healthy) &&
@@ -325,7 +335,7 @@ export async function ensureDetachedHubServer(
});
}
}
const nextExpected = await probeHubServer(expectedUrl);
const nextExpected = await safeProbeHubServer(expectedUrl);
if (nextExpected?.url && !isCompatibleHubRecord(nextExpected)) {
await retireIncompatibleHub(nextExpected, owner.discoveryPath);
}
@@ -110,6 +110,25 @@ describe("getProviderConfigFields", () => {
expect(result.fields.awsProfile?.optional).toBe(true);
});
it("returns SAP AI Core client credential fields instead of a generic API key", () => {
const result = getProviderConfigFields("sapaicore");
expect(result.providerId).toBe("sapaicore");
expect(result.authMethod).toBe("api-key");
expect(result.description).toMatch(/not a generic API key/i);
expect(Object.keys(result.fields)).toEqual([
"baseUrl",
"sapClientId",
"sapClientSecret",
"sapTokenUrl",
"sapResourceGroup",
"sapDeploymentId",
]);
expect(result.fields.apiKey).toBeUndefined();
expect(result.fields.baseUrl?.label).toBe("AI Core Base URL");
expect(result.fields.sapResourceGroup?.optional).toBe(true);
expect(result.fields.sapDeploymentId?.optional).toBe(true);
});
it("falls back to a single api-key field for unknown providers", () => {
const result = getProviderConfigFields("not-a-real-provider");
expect(result.authMethod).toBe("api-key");
@@ -5,7 +5,12 @@ export type ProviderConfigFieldKey =
| "apiKey"
| "baseUrl"
| "awsRegion"
| "awsProfile";
| "awsProfile"
| "sapClientId"
| "sapClientSecret"
| "sapTokenUrl"
| "sapResourceGroup"
| "sapDeploymentId";
export interface ProviderConfigFieldRequirement {
defaultValue?: string;
@@ -30,6 +35,11 @@ const FIELD_KEYS: ProviderConfigFieldKey[] = [
"baseUrl",
"awsRegion",
"awsProfile",
"sapClientId",
"sapClientSecret",
"sapTokenUrl",
"sapResourceGroup",
"sapDeploymentId",
];
interface ProviderConfigFieldMetadata {
@@ -71,6 +81,39 @@ const PROVIDER_CONFIG_FIELD_METADATA: Partial<
},
},
},
sapaicore: {
mode: "replace",
description:
"SAP AI Core uses OAuth client credentials and an AI Core API URL, not a generic API key.",
fields: {
baseUrl: {
label: "AI Core Base URL",
placeholder: "https://api.ai.<region>.aws.ml.hana.ondemand.com",
},
sapClientId: {
label: "Client ID",
placeholder: "sb-...|xsuaa_std!b...",
},
sapClientSecret: {
label: "Client Secret",
placeholder: "SAP AI Core client secret",
},
sapTokenUrl: {
label: "Token URL",
placeholder: "https://<subdomain>.authentication.sap.hana.ondemand.com",
},
sapResourceGroup: {
label: "Resource Group",
placeholder: "default",
optional: true,
},
sapDeploymentId: {
label: "Deployment ID",
placeholder: "SAP AI Core deployment id",
optional: true,
},
},
},
};
function mergeFields(
@@ -352,6 +352,122 @@ describe("migrateLegacyProviderSettings", () => {
});
expect(manager.read().providers.bedrock?.tokenSource).toBe("migration");
});
it("migrates legacy SAP AI Core credentials into SAP provider settings", () => {
const tempDir = mkdtempSync(
path.join(os.tmpdir(), "core-legacy-provider-"),
);
tempDirs.push(tempDir);
const providersPath = path.join(tempDir, "settings", "providers.json");
const manager = new ProviderSettingsManager({ filePath: providersPath });
writeFileSync(
path.join(tempDir, "globalState.json"),
JSON.stringify(
{
mode: "act",
actModeApiProvider: "sapaicore",
actModeApiModelId: "anthropic--claude-4.6-sonnet",
sapAiCoreBaseUrl: "https://api.ai.example.aws.ml.hana.ondemand.com",
sapAiCoreTokenUrl:
"https://example.authentication.sap.hana.ondemand.com",
sapAiResourceGroup: "default",
sapAiCoreUseOrchestrationMode: true,
actModeSapAiCoreDeploymentId: "deployment-id",
},
null,
2,
),
);
writeFileSync(
path.join(tempDir, "secrets.json"),
JSON.stringify(
{
sapAiCoreClientId: "sap-client",
sapAiCoreClientSecret: "sap-secret",
},
null,
2,
),
);
const result = migrateLegacyProviderSettings({
providerSettingsManager: manager,
dataDir: tempDir,
});
expect(result).toMatchObject({
migrated: true,
providerCount: 1,
lastUsedProvider: "sapaicore",
});
expect(manager.getProviderSettings("sapaicore")).toEqual({
provider: "sapaicore",
model: "anthropic--claude-4.6-sonnet",
baseUrl: "https://api.ai.example.aws.ml.hana.ondemand.com",
sap: {
clientId: "sap-client",
clientSecret: "sap-secret",
tokenUrl: "https://example.authentication.sap.hana.ondemand.com",
resourceGroup: "default",
deploymentId: "deployment-id",
useOrchestrationMode: true,
},
});
expect(manager.read().providers.sapaicore?.tokenSource).toBe("migration");
});
it("detects SAP AI Core legacy files even when provider mode is absent", () => {
const tempDir = mkdtempSync(
path.join(os.tmpdir(), "core-legacy-provider-"),
);
tempDirs.push(tempDir);
const providersPath = path.join(tempDir, "settings", "providers.json");
const manager = new ProviderSettingsManager({ filePath: providersPath });
writeFileSync(
path.join(tempDir, "globalState.json"),
JSON.stringify(
{
sapAiCoreBaseUrl: "https://api.ai.example.aws.ml.hana.ondemand.com",
sapAiCoreTokenUrl:
"https://example.authentication.sap.hana.ondemand.com",
},
null,
2,
),
);
writeFileSync(
path.join(tempDir, "secrets.json"),
JSON.stringify(
{
sapAiCoreClientId: "sap-client",
sapAiCoreClientSecret: "sap-secret",
},
null,
2,
),
);
const result = migrateLegacyProviderSettings({
providerSettingsManager: manager,
dataDir: tempDir,
});
expect(result).toMatchObject({
migrated: true,
lastUsedProvider: "sapaicore",
});
expect(manager.getProviderSettings("sapaicore")).toMatchObject({
provider: "sapaicore",
baseUrl: "https://api.ai.example.aws.ml.hana.ondemand.com",
sap: {
clientId: "sap-client",
clientSecret: "sap-secret",
tokenUrl: "https://example.authentication.sap.hana.ondemand.com",
},
});
});
});
// =============================================================================
@@ -460,7 +460,6 @@ function buildLegacyProviderSettings(
aihubmix: legacySecrets.aihubmixApiKey,
nousResearch: legacySecrets.nousResearchApiKey,
oca: legacySecrets.ocaApiKey,
sapaicore: legacySecrets.sapAiCoreClientId,
};
const providerSpecific: Partial<ProviderSettings> = {};
@@ -674,6 +673,16 @@ function collectCandidateProviderIds(
}
if (trimNonEmpty(legacySecrets.clineApiKey)) candidates.add("cline");
if (trimNonEmpty(legacySecrets.ocaApiKey)) candidates.add("oca");
if (
trimNonEmpty(legacySecrets.sapAiCoreClientId) ||
trimNonEmpty(legacySecrets.sapAiCoreClientSecret) ||
trimNonEmpty(legacyGlobalState.sapAiCoreTokenUrl) ||
trimNonEmpty(legacyGlobalState.sapAiCoreBaseUrl) ||
trimNonEmpty(legacyGlobalState.sapAiResourceGroup) ||
legacyGlobalState.sapAiCoreUseOrchestrationMode !== undefined
) {
candidates.add("sapaicore");
}
return candidates;
}
@@ -54,6 +54,14 @@ interface IndexResponseMessage {
const CACHE = new Map<string, CacheEntry>();
function canUseFileIndexWorker(): boolean {
if (!isMainThread) {
return false;
}
return true;
}
function pruneStaleCacheEntries(now: number): void {
if (CACHE.size <= 1) {
return;
@@ -276,7 +284,7 @@ startWorkerServer();
let workerClient: FileIndexWorkerClient | null | undefined;
function getWorkerClient(): FileIndexWorkerClient | null {
if (!isMainThread) {
if (!canUseFileIndexWorker()) {
return null;
}
if (workerClient === undefined) {
+4
View File
@@ -2,5 +2,9 @@
## Next Release
- SAP AI Core uses AI SDK community provider @jerome-benoit/sap-ai-provider https://ai-sdk.dev/providers/community-providers/sap-ai
## 0.0.42
- Supports Bedrock bearer API keys, direct IAM credentials, AWS profiles, and the default AWS SDK credential chain
- Routes Z.AI GLM thinking through provider metadata while preserving generic thinking suppression for non-GLM Z.AI custom models
+1
View File
@@ -54,6 +54,7 @@
"@ai-sdk/provider": "^3.0.8",
"@aws-sdk/credential-providers": "^3.922.0",
"@cline/shared": "workspace:*",
"@jerome-benoit/sap-ai-provider": "4.6.9",
"@langfuse/otel": "^4.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-trace-node": "^2.6.1",
@@ -857,6 +857,12 @@ async function createProviderModule(
const { createDifyProviderModule } = await import("./vendors/community");
return createDifyProviderModule(config);
}
case "sapaicore": {
const { createSapAiCoreProviderModule } = await import(
"./vendors/community"
);
return createSapAiCoreProviderModule(config);
}
}
}
@@ -994,3 +1000,4 @@ export const createClaudeCodeProvider = createAiSdkProvider("claude-code");
export const createOpenAICodexProvider = createAiSdkProvider("openai-codex");
export const createOpenCodeProvider = createAiSdkProvider("opencode");
export const createDifyProvider = createAiSdkProvider("dify");
export const createSapAiCoreProvider = createAiSdkProvider("sapaicore");
@@ -63,6 +63,10 @@ async function loadFamilyFactory(
const module = await import("./ai-sdk");
return module.createDifyProvider;
}
case "sap-ai-core": {
const module = await import("./ai-sdk");
return module.createSapAiCoreProvider;
}
}
})();
+15 -13
View File
@@ -40,7 +40,8 @@ export type ProviderFamily =
| "claude-code"
| "openai-codex"
| "opencode"
| "dify";
| "dify"
| "sap-ai-core";
export interface BuiltinSpec {
id: string;
@@ -245,6 +246,7 @@ function inferClient(spec: BuiltinSpec): ProviderClient {
case "openai-codex":
case "opencode":
case "dify":
case "sap-ai-core":
return "ai-sdk-community";
default:
return "openai-compatible";
@@ -638,18 +640,6 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
modelsFactory: () => ({}),
defaults: { baseUrl: "https://api.asksage.ai/server" },
},
{
id: "sapaicore",
name: "SAP AI Core",
description: "SAP AI Core inference and orchestration platform",
family: "openai-compatible",
client: "ai-sdk-community",
capabilities: ["tools", "reasoning", "prompt-cache"],
defaultModelId: "anthropic--claude-3.5-sonnet",
apiKeyEnv: ["AICORE_SERVICE_KEY", "VCAP_SERVICES"],
modelsProviderId: "sapaicore",
metadata: ANTHROPIC_ROUTING_METADATA,
},
];
export const BUILTIN_SPECS: BuiltinSpec[] = [
@@ -801,6 +791,18 @@ export const BUILTIN_SPECS: BuiltinSpec[] = [
apiKeyEnv: ["DIFY_API_KEY"],
modelsFactory: () => ({}),
},
{
id: "sapaicore",
name: "SAP AI Core",
description: "SAP AI Core inference and orchestration platform",
family: "sap-ai-core",
client: "ai-sdk-community",
capabilities: ["tools", "reasoning", "prompt-cache"],
defaultModelId: "anthropic--claude-3.5-sonnet",
apiKeyEnv: ["AICORE_SERVICE_KEY", "VCAP_SERVICES"],
modelsProviderId: "sapaicore",
metadata: ANTHROPIC_ROUTING_METADATA,
},
...OPENAI_COMPATIBLE_SPECS,
];
@@ -22,6 +22,7 @@ import {
createOpenAICompatibleProvider,
createOpenAIProvider,
createOpenCodeProvider,
createSapAiCoreProvider,
createVertexProvider,
} from "./ai-sdk";
import { BUILTIN_PROVIDER_REGISTRATIONS } from "./builtins-runtime";
@@ -160,6 +161,8 @@ function resolveFactory(
return createOpenCodeProvider;
case "dify":
return createDifyProvider;
case "sapaicore":
return createSapAiCoreProvider;
default:
return createOpenAICompatibleProvider;
}
+21 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { createOpenAICompatibleProvider, createOpenAIProvider } from "./ai-sdk";
import {
createOpenAICompatibleProvider,
createOpenAIProvider,
createSapAiCoreProvider,
} from "./ai-sdk";
import { BUILTIN_PROVIDER_REGISTRATIONS } from "./builtins-runtime";
import { createGateway } from "./gateway";
import { BUILT_IN_PROVIDER_IDS, normalizeProviderId } from "./ids";
@@ -120,4 +124,20 @@ describe("provider-ids", () => {
});
}
});
it("routes SAP AI Core through the SAP AI SDK provider factory", async () => {
await expect(getProvider("sapaicore")).resolves.toMatchObject({
id: "sapaicore",
name: "SAP AI Core",
client: "ai-sdk-community",
defaultModelId: "anthropic--claude-3.5-sonnet",
});
const registration = BUILTIN_PROVIDER_REGISTRATIONS.find(
(item) => item.manifest.id === "sapaicore",
);
await expect(registration?.loadProvider?.()).resolves.toMatchObject({
createProvider: createSapAiCoreProvider,
});
});
});
@@ -16,7 +16,8 @@ export type AiSdkProviderOptionsTarget =
| "claude-code"
| "openai-codex"
| "opencode"
| "dify";
| "dify"
| "sapaicore";
export type ProviderOptionSuppression = {
genericThinking?: boolean;
@@ -83,6 +84,8 @@ export function inferProviderOptionsTarget(
return "opencode";
case "dify":
return "dify";
case "sapaicore":
return "sapaicore";
default:
return "openai-compatible";
}
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it } from "vitest";
import { createSapAiCoreProviderModule } from "./community";
const originalServiceKey = process.env.AICORE_SERVICE_KEY;
describe("createSapAiCoreProviderModule", () => {
afterEach(() => {
if (originalServiceKey === undefined) {
delete process.env.AICORE_SERVICE_KEY;
} else {
process.env.AICORE_SERVICE_KEY = originalServiceKey;
}
});
it("passes SAP credentials as a provider destination without mutating process env", async () => {
process.env.AICORE_SERVICE_KEY = "existing-service-key";
const provider = await createSapAiCoreProviderModule({
providerId: "sapaicore",
baseUrl: "https://api.ai.example.aws.ml.hana.ondemand.com",
options: {
clientId: "sap-client",
clientSecret: "sap-secret",
tokenUrl: "https://auth.example",
deploymentId: "deployment-id",
},
});
const model = provider.model("anthropic--claude-4.6-sonnet") as {
config?: { destination?: Record<string, unknown> };
};
expect(process.env.AICORE_SERVICE_KEY).toBe("existing-service-key");
expect(model.config?.destination).toMatchObject({
authentication: "OAuth2ClientCredentials",
clientId: "sap-client",
clientSecret: "sap-secret",
tokenServiceUrl: "https://auth.example/oauth/token",
url: "https://api.ai.example.aws.ml.hana.ondemand.com",
});
});
it("fails fast for partial explicit SAP configuration", async () => {
await expect(
createSapAiCoreProviderModule({
providerId: "sapaicore",
options: {
clientId: "sap-client",
clientSecret: "sap-secret",
tokenUrl: "https://auth.example",
},
}),
).rejects.toThrow(/baseUrl/);
});
});
+110
View File
@@ -1,10 +1,15 @@
import type { GatewayResolvedProviderConfig } from "@cline/shared";
import type { SAPAIProviderSettings } from "@jerome-benoit/sap-ai-provider";
import { createClaudeCode } from "ai-sdk-provider-claude-code";
import { createCodexExec } from "ai-sdk-provider-codex-cli";
import { createDifyProvider } from "dify-ai-provider";
import { resolveApiKey } from "../http";
import type { ProviderFactoryResult } from "./types";
type SapAiProviderModule = typeof import("@jerome-benoit/sap-ai-provider");
type SapDestination = NonNullable<SAPAIProviderSettings["destination"]>;
const SAP_AI_PROVIDER_PACKAGE = "@jerome-benoit/sap-ai-provider";
function readOptions(
config: GatewayResolvedProviderConfig,
): Record<string, unknown> {
@@ -95,3 +100,108 @@ export async function createDifyProviderModule(
}),
};
}
function readStringOption(
options: Record<string, unknown>,
key: string,
): string | undefined {
const value = options[key];
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function normalizeSapTokenServiceUrl(tokenUrl: string): string {
const trimmed = tokenUrl.replace(/\/+$/, "");
return /\/oauth\/token$/i.test(trimmed) ? trimmed : `${trimmed}/oauth/token`;
}
function hasExplicitSapConnectionConfig(
config: GatewayResolvedProviderConfig,
options: Record<string, unknown>,
): boolean {
return Boolean(
config.apiKey?.trim() ||
config.baseUrl?.trim() ||
readStringOption(options, "clientId") ||
readStringOption(options, "clientSecret") ||
readStringOption(options, "tokenUrl"),
);
}
function buildSapDestination(
config: GatewayResolvedProviderConfig,
options: Record<string, unknown>,
): SapDestination | undefined {
const clientId = readStringOption(options, "clientId");
const clientSecret =
readStringOption(options, "clientSecret") ?? config.apiKey?.trim();
const tokenUrl = readStringOption(options, "tokenUrl");
const baseUrl = config.baseUrl?.trim();
if (!clientId || !clientSecret || !tokenUrl || !baseUrl) {
if (!hasExplicitSapConnectionConfig(config, options)) {
return undefined;
}
const missing = [
!clientId ? "sap.clientId" : undefined,
!clientSecret ? "sap.clientSecret" : undefined,
!tokenUrl ? "sap.tokenUrl" : undefined,
!baseUrl ? "baseUrl" : undefined,
].filter(Boolean);
throw new Error(
`SAP AI Core provider is missing required configuration: ${missing.join(
", ",
)}.`,
);
}
return {
authentication: "OAuth2ClientCredentials" as const,
clientId,
clientSecret,
name: config.providerId,
tokenServiceUrl: normalizeSapTokenServiceUrl(tokenUrl),
url: baseUrl.replace(/\/+$/, ""),
} satisfies SapDestination;
}
function resolveSapApi(options: Record<string, unknown>) {
const api = options.api;
if (api === "orchestration" || api === "foundation-models") {
return api;
}
if (options.useOrchestrationMode === false) {
return "foundation-models";
}
return "orchestration";
}
async function importSapAiProvider(): Promise<SapAiProviderModule> {
const specifier: string = SAP_AI_PROVIDER_PACKAGE;
return import(specifier) as Promise<SapAiProviderModule>;
}
export async function createSapAiCoreProviderModule(
config: GatewayResolvedProviderConfig,
): Promise<ProviderFactoryResult> {
const options = readOptions(config);
const destination = buildSapDestination(config, options);
const { createSAPAIProvider } = await importSapAiProvider();
const deploymentId = readStringOption(options, "deploymentId");
const provider = createSAPAIProvider({
name: config.providerId,
...(deploymentId
? { deploymentId }
: { resourceGroup: readStringOption(options, "resourceGroup") }),
api: resolveSapApi(options),
...(destination ? { destination } : {}),
...(typeof options.defaultSettings === "object" &&
options.defaultSettings !== null &&
!Array.isArray(options.defaultSettings)
? { defaultSettings: options.defaultSettings }
: {}),
});
return {
model: (modelId) => provider(modelId),
};
}
@@ -94,6 +94,8 @@ export type ConnectDiscordOptions = {
applicationId: string;
botToken: string;
publicKey: string;
ownerUserId?: string;
allowBotAuthors: boolean;
mentionRoleIds?: string[];
cwd: string;
model?: string;