From 1ea21d14c9b4466d68d607a9f18f536f40ee7ee0 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Thu, 20 Aug 2026 14:30:49 +0800 Subject: [PATCH 1/4] feat(acp): support terminal authentication --- docs/README.md | 1 + docs/features/acp-terminal-auth/plan.md | 131 + docs/features/acp-terminal-auth/spec.md | 405 + docs/features/acp-v1-reliability/plan.md | 8 +- docs/features/acp-v1-reliability/spec.md | 4 +- resources/acp-registry/registry.json | 72 +- resources/model-db/providers.json | 9385 +++++++++++++---- src/main/agent/acp/auth/acpAuthService.ts | 232 + .../agent/acp/auth/acpTerminalAuthRunner.ts | 98 + .../client/connection/AcpConnectionManager.ts | 3 +- .../agent/acp/instance/acpAgentRuntime.ts | 2 + src/main/agent/acp/launch/acpInitHelper.ts | 703 -- src/main/agent/acp/routes.ts | 76 +- .../agent/acp/runtime/acpAuthentication.ts | 38 + .../agent/acp/runtime/acpProcessManager.ts | 313 +- .../agent/acp/runtime/acpSessionManager.ts | 43 +- src/main/app/composition.ts | 24 +- src/main/session/contracts.ts | 7 +- src/main/session/lifecycle.ts | 35 +- src/main/session/routes.ts | 6 +- src/renderer/api/AcpAuthClient.ts | 41 + src/renderer/api/AcpTerminalClient.ts | 67 - src/renderer/api/SessionClient.ts | 2 +- src/renderer/api/index.ts | 2 +- .../settings/components/AcpSettings.vue | 52 +- .../src/components/acp/AcpAuthDialog.vue | 237 + src/renderer/src/i18n/da-DK/settings.json | 19 + src/renderer/src/i18n/de-DE/settings.json | 19 + src/renderer/src/i18n/en-US/settings.json | 19 + src/renderer/src/i18n/es-ES/settings.json | 19 + src/renderer/src/i18n/fa-IR/settings.json | 19 + src/renderer/src/i18n/fr-FR/settings.json | 19 + src/renderer/src/i18n/he-IL/settings.json | 19 + src/renderer/src/i18n/id-ID/settings.json | 19 + src/renderer/src/i18n/it-IT/settings.json | 19 + src/renderer/src/i18n/ja-JP/settings.json | 19 + src/renderer/src/i18n/ko-KR/settings.json | 19 + src/renderer/src/i18n/ms-MY/settings.json | 19 + src/renderer/src/i18n/pl-PL/settings.json | 19 + src/renderer/src/i18n/pt-BR/settings.json | 19 + src/renderer/src/i18n/ru-RU/settings.json | 19 + src/renderer/src/i18n/tr-TR/settings.json | 19 + src/renderer/src/i18n/vi-VN/settings.json | 19 + src/renderer/src/i18n/zh-CN/settings.json | 19 + src/renderer/src/i18n/zh-HK/settings.json | 19 + src/renderer/src/i18n/zh-TW/settings.json | 19 + src/renderer/src/pages/NewThreadPage.vue | 67 +- src/shared/contracts/events.ts | 17 +- .../contracts/events/acp-auth.events.ts | 24 + .../contracts/events/acp-terminal.events.ts | 62 - src/shared/contracts/routes.ts | 17 +- .../contracts/routes/acp-auth.routes.ts | 84 + .../contracts/routes/acp-terminal.routes.ts | 20 - .../contracts/routes/sessions.routes.ts | 15 +- src/shared/types/acp.ts | 36 + .../agent/acp/auth/acpAuthService.test.ts | 187 + .../acp/auth/acpTerminalAuthRunner.test.ts | 32 + .../acp/runtime/acpProcessManager.test.ts | 138 +- .../acpProcessManagerCapabilities.test.ts | 23 +- .../acp/runtime/acpSessionManager.test.ts | 52 +- test/main/routes/contracts.test.ts | 14 +- test/main/routes/dispatcher.test.ts | 49 +- test/main/session/lifecycle.test.ts | 44 +- test/main/session/session.integration.test.ts | 16 +- test/renderer/api/clients.test.ts | 69 +- .../renderer/components/AcpAuthDialog.test.ts | 161 + test/renderer/components/AcpSettings.test.ts | 44 + .../NewThreadPage.onboarding.test.ts | 1 + .../renderer/components/NewThreadPage.test.ts | 100 +- test/renderer/pages/NewThreadPage.test.ts | 1 + 70 files changed, 10789 insertions(+), 2851 deletions(-) create mode 100644 docs/features/acp-terminal-auth/plan.md create mode 100644 docs/features/acp-terminal-auth/spec.md create mode 100644 src/main/agent/acp/auth/acpAuthService.ts create mode 100644 src/main/agent/acp/auth/acpTerminalAuthRunner.ts delete mode 100644 src/main/agent/acp/launch/acpInitHelper.ts create mode 100644 src/main/agent/acp/runtime/acpAuthentication.ts create mode 100644 src/renderer/api/AcpAuthClient.ts delete mode 100644 src/renderer/api/AcpTerminalClient.ts create mode 100644 src/renderer/src/components/acp/AcpAuthDialog.vue create mode 100644 src/shared/contracts/events/acp-auth.events.ts delete mode 100644 src/shared/contracts/events/acp-terminal.events.ts create mode 100644 src/shared/contracts/routes/acp-auth.routes.ts delete mode 100644 src/shared/contracts/routes/acp-terminal.routes.ts create mode 100644 test/main/agent/acp/auth/acpAuthService.test.ts create mode 100644 test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts create mode 100644 test/renderer/components/AcpAuthDialog.test.ts diff --git a/docs/README.md b/docs/README.md index 067e5dcbec..6170247145 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ | --- | --- | | [architecture/local-control-plane/](./architecture/local-control-plane/) | CLI V1 已实现;全量测试与生产构建通过,当前平台 unpack 受发布 runtime 下载网络阻塞 | | [features/acp-v1-reliability/](./features/acp-v1-reliability/) | ACP capability、auth、session lifecycle 与 diagnostics 待实施 | +| [features/acp-terminal-auth/](./features/acp-terminal-auth/) | Issue #2144;ACP v1 Preview terminal auth、交互终端、重连与一次性 session retry 待实施 | | [features/cua-cross-platform-computer-use/](./features/cua-cross-platform-computer-use/) | 已实现主体,等待 CI platform matrix 验证 | | [features/mcp-oauth-authentication/](./features/mcp-oauth-authentication/) | 已实现主体,等待真实 OAuth smoke | | [architecture/mcp-v2-protocol/](./architecture/mcp-v2-protocol/) | v2 与 legacy wire 已落地,等待外部互操作验证及兼容窗口结束 | diff --git a/docs/features/acp-terminal-auth/plan.md b/docs/features/acp-terminal-auth/plan.md new file mode 100644 index 0000000000..462338fa66 --- /dev/null +++ b/docs/features/acp-terminal-auth/plan.md @@ -0,0 +1,131 @@ +# ACP Terminal Authentication Implementation Plan + +## Objective + +Implement the protocol-correct terminal-authentication flow in +[`spec.md`](./spec.md) for Registry and manual ACP agents, with direct process execution, scoped PTY +ownership, reconnect/reinitialize behavior, and a one-shot session preparation retry. + +Implementation owns the current feature branch. Validation is implementation-first: existing checks +may run during development, but durable tests are selected and added after the behavior is complete. + +## Ownership Boundary + +- `src/main/agent/acp/runtime/acpProcessManager.ts`: capability advertisement, immutable launch + snapshot, method validation, auth run serialization, connection replacement, and auth challenge + lifecycle. +- `src/main/agent/acp/runtime/acpSessionManager.ts`: numeric `auth_required` detection before + resume/load/new fallback and retry eligibility. +- `src/main/agent/acp/instance/*`: expose `auth_required` without duplicating authentication logic. +- `src/main/agent/acp/launch/*`: replace the unused shell-injection path with a narrow direct PTY + auth runner. +- `src/shared/contracts/*` and `src/renderer/api/*`: renderer-safe auth routes/events and client. +- `src/renderer/src/pages/NewThreadPage.vue` and `src/renderer/settings/components/AcpSettings.vue`: + onboarding/auth-required entry points and one-shot retry. +- A small shared renderer component owns the method chooser and xterm dialog; it contains no launch + or protocol decisions. +- `src/main/provider/providers/acpProvider.ts` delegates compatibility actions to the shared runtime. + +## Plan + +### 1. Align Capability and Auth Types + +- [x] Add renderer-safe auth method, challenge, run-state, and draft-result types without exposing raw + SDK objects or environment values. +- [x] Inject `terminalAuthAvailable` into ACP runtime composition and pass it to + `buildClientCapabilities`. +- [x] Normalize missing auth type to `agent`, `terminal` to supported terminal auth, and legacy or + unknown types to `unsupported`. +- [x] Add numeric `RequestError.code === -32000` detection shared by session preparation paths. +- [x] Completion: initialization advertises no capability beyond the constructed product surface, + and auth-required can be represented without parsing error messages. + +### 2. Share the Materialized Launch + +- [x] Extract command rewrite, args, environment, PATH, Registry/toolchain settings, cwd validation, + and signature generation from protocol spawning into one materialization function. +- [x] Store an immutable materialized launch snapshot on each initialized handle. +- [x] Keep environment values redacted from logs and renderer payloads. +- [x] Make settings or installation changes invalidate challenges through the existing launch + signature refresh path. +- [x] Completion: protocol and auth processes consume the same verified command, base args, env, and + cwd snapshot. + +### 3. Implement the Direct PTY Auth Runner + +- [x] Replace or retire the unused `AcpInitHelper` shell-command injection and global active-shell + singleton. +- [x] Implement `AcpTerminalAuthRunner` with direct `node-pty.spawn(command, args)` execution, + caller-owned input, targeted output, exit observation, cancellation, and shutdown cleanup. +- [x] Append method args after base args and apply method env after the materialized base env. +- [x] Treat only exit status `0` as terminal-flow success; do not inspect output patterns. +- [x] Bound input/output event payloads and cancel the process when the initiating renderer is + destroyed. +- [x] Completion: no terminal-auth path invokes a shell or accepts a command from the auth + descriptor. + +### 4. Orchestrate Agent and Terminal Authentication + +- [x] Add current-handle method validation, per-agent/workdir serialization, stale challenge + rejection, and a one-shot draft retry boundary. +- [x] For `agent` methods, call `connection.authenticate({ methodId })` and keep the connection. +- [x] For `terminal` methods, keep the protocol connection during interaction, then on exit `0` + dispose it, reconnect, and reinitialize without calling `authenticate`. +- [x] Fail the auth run if reconnect/reinitialize fails; never report terminal exit alone as ready. +- [x] Ensure cancellation, non-zero exit, signal termination, app shutdown, and a second + `auth_required` result consume no additional retry. +- [x] Make the compatibility ACP provider delegate to this shared orchestration. +- [x] Completion: exactly one runtime implementation serves direct and compatibility ACP callers. + +### 5. Surface Auth Required and Retry Session Preparation + +- [x] Stop resume/load/new fallback immediately on numeric `auth_required` and create a typed + challenge from the current handle. +- [x] Preserve ACP instance/session state so a reusable local draft can expose `auth_required` + without claiming a remote session exists. +- [x] Change `sessions.ensureAcpDraft` to return `ready` or `auth_required` as a discriminated result. +- [x] After successful authentication, invoke the same idempotent draft/session preparation once; + stop on any retry failure. +- [x] Do not replay a user prompt as part of authentication recovery. +- [x] Completion: a first-time user can authenticate and reach a ready ACP draft without manually + switching agents or restarting DeepChat. + +### 6. Add Typed IPC and UI + +- [x] Add caller-scoped `acpAuth.inspect/start/input/cancel/status` routes and targeted + `output/stateChanged` events. +- [x] Remove or migrate the unscoped `acpTerminal.input/kill` routes after updating every reference. +- [x] Add an ACP auth dialog with method selection, xterm output/input, cancellation, reconnecting, + success, and failure states. +- [x] Integrate the compact auth-required card into `NewThreadPage` and `Check sign-in` into Registry + and manual cards in `AcpSettings`. +- [x] Add vue-i18n copy and regenerate i18n types; do not add raw user-visible strings. +- [x] Completion: multiple methods are understandable and only the initiating renderer owns the + terminal interaction. + +### 7. Whole-Change Review and Durable Regression Protection + +- [x] Review capability truthfulness, method discrimination, launch snapshot freshness, environment + precedence, shell avoidance, process-tree cleanup, renderer ownership, retry bounds, and log + redaction against the spec. +- [x] Add focused main tests for capability off/on, numeric auth detection before fallback, agent + authenticate, terminal non-authenticate, exact direct launch, env precedence, reconnect, + cancellation, stale challenge, concurrent starts, caller destruction, and one-shot retry. +- [x] Add renderer tests for single/multiple/unsupported methods, terminal states, cancellation, + auth-required draft retry, and settings probe behavior. +- [x] Run a manual interoperability matrix with one fake deterministic ACP agent and, when locally + available, `mcode acp`; keep MiniMax observations out of product conditionals. +- [x] Remove temporary probes, terminal transcripts, and implementation-coupled tests. +- [x] Completion: acceptance criteria have durable evidence without broad unrelated coverage. + +### 8. Quality Gates + +- [x] Run `pnpm run format`. +- [x] Run `pnpm run i18n`. +- [x] Run `pnpm run lint`. +- [x] Run `pnpm run typecheck`. +- [x] Run the smallest relevant main ACP and renderer suites, including the new auth tests. +- [x] Confirm normal DeepChat agents, pre-authenticated ACP agents, and ACP agent-requested terminals + retain existing behavior. +- [x] Update this plan's checkboxes only when the corresponding implementation slice and its selected + validation are complete. diff --git a/docs/features/acp-terminal-auth/spec.md b/docs/features/acp-terminal-auth/spec.md new file mode 100644 index 0000000000..054d15665a --- /dev/null +++ b/docs/features/acp-terminal-auth/spec.md @@ -0,0 +1,405 @@ +# ACP Terminal Authentication Specification + +> Status: implemented. Tracks [#2144](https://github.com/ThinkInAIXYZ/deepchat/issues/2144). +> This document is authoritative for the terminal-authentication slice of +> `docs/features/acp-v1-reliability/`. + +## Context + +DeepChat can start ACP v1 agents, initialize a connection, preserve the returned `authMethods`, and +execute agent-requested terminal commands. It cannot complete the separate interactive terminal +login flow defined by ACP. The normal process initializer currently advertises filesystem and +terminal capabilities, but omits `clientCapabilities.auth.terminal`; an agent therefore cannot +advertise a `terminal` authentication method to the normal product flow. + +The missing product path affects both Registry agents and manual agents. A user with credentials +already stored by an agent can create sessions, while a first-time user receives an authentication +failure without an actionable login surface. + +The current repository already contains the required foundations: + +- `@agentclientprotocol/sdk` is `0.16.1` and includes the v1 `AuthMethodTerminal` and + `clientCapabilities.auth.terminal` types. +- `buildClientCapabilities` already accepts `enableTerminalAuth`. +- `AcpProcessHandle` already retains `authMethods` from `initialize`. +- `AcpLaunchSpecService` and `AcpProcessManager` already resolve Registry and manual launch specs and + start the protocol process without a shell. +- `node-pty` and `@xterm/xterm` are already dependencies. +- Typed ACP terminal routes/events exist, but their old shell-injection helper is not connected to a + renderer product flow and is not safe to reuse unchanged. + +No new dependency or ACP SDK upgrade is required. The feature is implementable on the current +`dev` branch. + +## Protocol Sources and Correction + +The normative references are: + +- [ACP v1 authentication](https://agentclientprotocol.com/protocol/v1/draft/authentication) +- [Terminal Authentication RFD](https://agentclientprotocol.com/rfds/auth-methods) +- [ACP v1 overview](https://agentclientprotocol.com/protocol/v1/overview) + +Terminal authentication is currently a Preview capability carried by the v1 draft schema. The +installed TypeScript SDK still marks the relevant generated types as unstable, so the feature must +remain isolated behind capability negotiation and protocol-shaped tests. + +The implementation intentionally corrects one requirement in issue #2144: after a terminal login +process succeeds, DeepChat **must not** call `authenticate` with the terminal method ID. ACP defines +terminal authentication as an out-of-band flow: + +1. initialize and receive a `terminal` auth method; +2. run the configured agent program interactively with the method's additional arguments and + environment; +3. treat only exit status `0` as success; +4. reconnect and reinitialize the ACP agent; +5. retry the operation that received `auth_required`. + +`authenticate({ methodId })` remains valid only for an `agent` auth method, including a method with +no explicit `type` discriminator. + +## Goals + +- Advertise `clientCapabilities.auth.terminal=true` only when the bundled desktop runtime can + direct-spawn and present the interactive login flow. +- Preserve and expose all returned authentication methods without treating their presence as proof + that authentication is required. +- Convert ACP error code `-32000` into a typed, actionable auth-required state. +- Let the user select among multiple methods and complete `agent` or `terminal` authentication. +- Run terminal authentication with the exact materialized launch command used by the live ACP + connection, base arguments followed by method arguments, and method environment overriding the + base environment. +- Reconnect after successful terminal login and retry one blocked session preparation exactly once. +- Handle cancellation, non-zero exit, process failure, stale challenges, window closure, and app + shutdown without leaking processes or retrying unexpectedly. +- Apply the same behavior to Registry and manual agents without agent-specific branches. + +## Non-Goals + +- Do not add MiniMax Code or any other agent as a DeepChat-specific built-in. Discovery remains an + ACP Registry concern. +- Do not implement ACP v2. +- Do not parse terminal output to infer success. ACP defines the exit status as the interoperable + signal. +- Do not collect or persist credentials in DeepChat. The interactive agent process owns them. +- Do not productize the legacy `env_var` auth descriptor in this change. It is rendered as + unsupported with guidance to use the existing manual environment override. +- Do not automatically choose a method when the agent advertises more than one supported method. +- Do not automatically replay a user prompt. Only session preparation blocked before a prompt is + eligible for the one-shot retry. +- Do not reuse `AcpTerminalManager`; it implements agent-to-client `terminal/*` requests inside an + ACP session, not the client-owned out-of-band authentication process. + +## Ownership + +Direct ACP execution remains owned by `AcpRuntimeOwner`, `AcpAgentRuntime`, and +`AcpAgentInstance`. `AcpProvider` remains a compatibility adapter and must delegate authentication +to the shared runtime instead of adding a second flow. + +```text +NewThreadPage / AcpSettings / AcpAuthDialog + | + typed acpAuth routes + | + AcpRuntimeOwner + | + AcpProcessManager + / \ + live ACP connection AcpTerminalAuthRunner + \ / + reconnect + reinitialize + | + retry session preparation once +``` + +Only one new runtime unit is justified: `AcpTerminalAuthRunner`, a narrow PTY lifecycle wrapper. +Challenge state, method validation, connection replacement, and retry eligibility stay in +`AcpProcessManager` and the existing session/runtime layers. + +The unused shell-injection behavior in `AcpInitHelper` must be retired or reduced to the new direct +PTY runner. DeepChat must not retain two competing interactive ACP launch paths. + +## Capability Negotiation + +`AcpProcessManager` receives an injected `terminalAuthAvailable` capability. Production composition +sets it only after the direct PTY runner and typed renderer surface are registered successfully; +tests can explicitly set it to `false`. + +```typescript +buildClientCapabilities({ + enableFs: true, + enableTerminal: true, + enableTerminalAuth: terminalAuthAvailable +}) +``` + +Invariants: + +- `auth.terminal` is advertised during `initialize`, before an agent can return terminal methods. +- The capability describes implemented client behavior; it is not enabled only after a login has + already completed. +- If the runner cannot be constructed, DeepChat omits the capability and fails closed. +- Remote or headless callers may observe an auth-required state, but cannot start an interactive + terminal flow without a live local renderer caller. +- Agent-requested `terminal/*` support and terminal-auth support remain separate capabilities and + code paths. + +## Runtime Auth Model + +Authentication state is runtime-derived and scoped by `(agentId, canonicalWorkdir, +launchSignature)`. It is not persisted as an account truth because ACP does not expose an +authenticated-state query and agents may invalidate credentials independently. + +```typescript +type AcpAuthMethodView = { + id: string + name: string + description?: string + type: 'agent' | 'terminal' | 'unsupported' +} + +type AcpAuthChallenge = { + id: string + agentId: string + agentName: string + workdir: string + methods: AcpAuthMethodView[] + origin: 'draft_session' | 'session_prepare' | 'settings_probe' + sessionId?: string +} + +type AcpAuthRunState = + | 'required' + | 'running' + | 'reconnecting' + | 'succeeded' + | 'cancelled' + | 'failed' +``` + +Rules: + +- Missing `type` is normalized to `agent`. +- `terminal` is supported only when the capability was advertised and the descriptor came from the + current live handle. +- Legacy `env_var` and unknown discriminators are preserved for diagnostics but normalized to + `unsupported` for product control flow. +- `authMethods.length > 0` means methods are available; only ACP error code `-32000` means an + operation currently requires authentication. +- Challenges become stale when their connection exits, agent settings change, workdir changes, or + `launchSignature` no longer matches. +- At most one auth run may exist for an agent/workdir pair. Repeating the same challenge returns its + current state to the owning renderer; a different challenge for the same scope is rejected. + +## Launch Materialization + +The protocol connection and terminal-auth process must share one materialization function. Extract +the existing command rewrite, environment merge, PATH handling, Registry/toolchain settings, user +override, and cwd validation from `spawnAgentProcess` into a pure result: + +```typescript +type AcpMaterializedLaunch = { + command: string + args: string[] + env: Record + cwd: string +} +``` + +The immutable materialized launch snapshot and its separate launch signature are stored on the +initialized process handle. A terminal challenge uses that internal snapshot rather than exposing +it to the renderer or resolving settings again, which prevents a time-of-check / time-of-use change +between `initialize` and login. + +For a selected terminal method, the runner executes the equivalent of: + +```typescript +pty.spawn(materialized.command, [...materialized.args, ...(method.args ?? [])], { + cwd: materialized.cwd, + env: { ...materialized.env, ...(method.env ?? {}) } +}) +``` + +The preview command shown to the user may be shell-escaped for display only. It must never be fed +back to a shell. The method cannot replace `command`, and neither arguments nor environment values +are interpolated. + +Environment values and PTY output must not enter ordinary info logs. Diagnostics may record key +names, argument counts, process IDs, exit status, and bounded error text, but not credential values +or unredacted terminal transcripts. + +## Authentication Flows + +### Detecting Auth Required + +`AcpSessionManager` identifies `RequestError.code === -32000` before its existing resume/load/new +fallback logic. Auth-required errors must not be swallowed as an ordinary resume/load failure and +must not cause a fallback `session/new` call. + +The session layer creates an `AcpAuthChallenge` from the current process handle and returns an +actionable state to the renderer. A newly created local draft remains reusable; no remote ACP +session has been created yet. + +### Agent Method + +For `type='agent'` or an omitted type: + +1. validate the selected ID against the current handle's auth methods; +2. call `connection.authenticate({ methodId })` on the existing ACP connection; +3. mark the auth run successful only when the request succeeds; +4. retry eligible session preparation once on the same connection; +5. stop if the retry again returns `auth_required` or any other error. + +### Terminal Method + +For `type='terminal'`: + +1. validate caller ownership, challenge freshness, method ID, type, and launch signature; +2. keep the existing protocol connection alive while a separate PTY process runs; +3. direct-spawn the materialized command with base args plus method args and the merged env; +4. stream PTY output only to the renderer that started the run and forward bounded user input; +5. on cancellation, window closure, app shutdown, missing exit status, signal termination, or + non-zero exit, terminate the PTY tree and do not retry; +6. on exit status `0`, dispose the old ACP process handle; +7. start a fresh connection with the same agent/workdir and run `initialize` again; +8. do **not** call `authenticate` with the terminal method ID; +9. retry eligible session preparation once; +10. stop after that retry regardless of outcome, preventing authentication loops. + +If reconnect fails, the run ends in `failed`; a zero terminal exit alone must not be reported as a +usable ACP connection. + +### Retry Contract + +The existing `sessions.ensureAcpDraft` path returns a discriminated result: + +```typescript +type EnsureAcpDraftResult = + | { status: 'ready'; session: SessionWithState } + | { status: 'auth_required'; session: SessionWithState; challenge: AcpAuthChallenge } +``` + +After an auth route returns `succeeded`, `NewThreadPage` invokes the same idempotent draft ensure +operation once. It reuses the local draft and performs the normal resume/load/new decision against +the refreshed connection. The renderer must not loop automatically when the retry fails. + +This slice automatically retries only the new-thread draft preparation path. An existing session can +recover after authentication from ACP settings and a subsequent user action, but DeepChat must not +automatically replay a prompt that may already have been handed to the agent. + +## Typed Routes and Events + +Add an auth-specific contract rather than exposing raw ACP payloads to the renderer: + +- `acpAuth.inspect`: initialize or reuse a handle and return renderer-safe methods/status. +- `acpAuth.start`: select a method and run agent or terminal authentication. +- `acpAuth.input`: send bounded input to the caller-owned PTY run. +- `acpAuth.cancel`: cancel the caller-owned run. +- `acpAuth.status`: recover current state after a settings view remount. +- `acpAuth.output`: targeted PTY output event with `runId` and bounded data. +- `acpAuth.stateChanged`: targeted lifecycle event with no environment or transcript data. + +Every mutation route must use `RouteContext` to bind the run to the initiating `webContentsId`. +Input, cancel, and output delivery must reject or ignore a different renderer. A destroyed initiating +window cancels its active PTY run. + +The existing global `acpTerminal.input/kill` singleton contract must not remain as an unscoped path. +It is either migrated to the run-ID/caller-owned auth contract or removed after all references are +updated. + +## UI/UX + +The primary onboarding surface is `NewThreadPage`, where the user has already selected an ACP agent +and a valid workdir. `AcpSettings` also exposes `Check sign-in` on installed Registry and manual +agent cards; when no configured or reusable workdir exists, the runtime uses its constrained ACP +temporary workdir. + +Before: + +```text ++----------------------------------------------------+ +| Agent: MiniMax Code Workspace: /work/project | +| | +| Session preparation fails; no actionable UI. | ++----------------------------------------------------+ +``` + +After an auth-required response: + +```text ++----------------------------------------------------+ +| MiniMax Code needs sign-in | +| Choose how to authenticate before starting chat. | +| Method: [Log in from terminal v] | +| [Open terminal] [Cancel] | ++----------------------------------------------------+ +``` + +Interactive terminal: + +```text ++----------------------------------------------------+ +| Sign in to MiniMax Code [x] | +|----------------------------------------------------| +| $ interactive agent output... | +| > | +|----------------------------------------------------| +| Running [Cancel sign-in] | ++----------------------------------------------------+ +``` + +UX rules: + +- If exactly one supported method exists, preselect it; otherwise require an explicit selection. +- Unsupported/legacy methods remain visible but disabled with a concise explanation. +- Display `Signing in`, `Reconnecting`, `Ready`, `Cancelled`, and `Failed` as distinct states. +- Closing the dialog while the PTY is running cancels the process. +- A successful terminal exit displays `Reconnecting` until the new ACP initialization succeeds. +- Do not label the user authenticated merely because `initialize` returned auth methods. +- User-facing copy uses vue-i18n and the existing compact settings/onboarding visual language. + +## Compatibility and Failure Behavior + +- Registry and manual agents use the same resolved launch pipeline. +- Existing pre-authenticated agents continue directly to session preparation. +- Agents that expose only `agent` auth continue to use ACP `authenticate` without a PTY. +- Agents that do not advertise auth methods retain the current error path, augmented with a clear + diagnostic that no supported method was supplied. +- Capability-disabled tests must prove no terminal method can enter product control flow. +- Updating an agent command, args, environment, installation, or workdir invalidates outstanding + challenges and active warm handles through the existing launch-signature refresh path. +- Non-ACP providers and agent-requested terminal operations are unchanged. +- MiniMax Code (`mcode acp`) is a manual interoperability sample until it is available through the + official ACP Registry; no special source code or migration is added for it. + +## Acceptance Criteria + +- A capable desktop initialization sends `clientCapabilities.auth.terminal=true`; an injected + unavailable runner omits it. +- Terminal auth methods returned by `initialize` are present in a renderer-safe auth challenge. +- ACP error code `-32000` from resume, load, or new session stops fallback and produces + `auth_required`. +- Selecting a terminal method direct-spawns the exact live command, appends method args to base + args, applies method env last, preserves cwd, and never invokes a shell. +- DeepChat never calls ACP `authenticate` for a terminal method. +- Exit `0` disposes the old connection, reconnects, reinitializes, and retries session preparation + once. +- Non-zero exit, missing exit status, cancellation, caller destruction, stale challenge, reconnect + failure, and second auth-required result do not retry. +- Multiple supported methods require user selection and the selected ID is revalidated in main. +- Only the initiating renderer can read output, send input, or cancel the run. +- No terminal transcript or auth environment value is persisted or written to ordinary logs. +- Registry and manual agents share the same implementation; no MiniMax-specific branch exists. +- Relevant main and renderer regression tests pass on macOS, Windows, and Linux CI targets. + +## Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| Preview schema changes in a later SDK | Keep schema handling localized, capability-gated, and covered by descriptor-shape tests | +| Shell injection through agent args | Direct `node-pty.spawn(command, args)` only; display strings are never executed | +| A settings edit changes the binary before login | Bind challenges to the immutable live launch signature and reject stale runs | +| Multiple windows race or read terminal output | Bind every run and event to the initiating `webContentsId` | +| Resume/load fallback hides auth required | Match numeric error code before fallback and surface one typed challenge | +| Exit zero but the new connection is unusable | Report success only after reconnect and `initialize` complete | +| Automatic retry loops | Close the resolved challenge before one explicit draft retry; a repeated auth-required response remains actionable but is not retried again | +| Secrets leak through logs | Log metadata only; never log env values or PTY transcripts | diff --git a/docs/features/acp-v1-reliability/plan.md b/docs/features/acp-v1-reliability/plan.md index 2512037d7e..37509e03ca 100644 --- a/docs/features/acp-v1-reliability/plan.md +++ b/docs/features/acp-v1-reliability/plan.md @@ -126,7 +126,9 @@ interface AcpCapabilitySnapshot { ``` - `buildClientCapabilities` 只声明 DeepChat 已真实支持的能力。 -- 首轮实现中,`fs`、`terminal` 继续声明;`auth.terminal` 只能在 terminal auth flow 完成后声明。 +- `fs`、`terminal` 继续声明;`auth.terminal` 只在交互 runner、typed route/event 和 renderer + surface 已实现且可用时随 `initialize` 声明。Terminal auth 的规范以 + `docs/features/acp-terminal-auth/spec.md` 为准。 - 初始化失败分三类展示:protocol version mismatch、process exited、timeout。 - 初始化返回的 `models`、`modes`、`configOptions` 统一走 `normalizeAcpConfigState`,并发布 ready event。 @@ -143,8 +145,8 @@ interface AcpCapabilitySnapshot { | Auth type | 对接方式 | | --- | --- | | `agent` 或默认类型 | 直接调用 `connection.authenticate({ methodId })`,成功后刷新 status;失败保留错误详情 | -| `env_var` | 在 agent settings 中标出必需 env var;缺失时不启动 prompt;设置后重启 agent 并重新 initialize | -| `terminal` | 在 DeepChat 控制的 terminal/auth runner 中执行 agent 指定流程;完成后重新 initialize;只有该能力完成后才声明 `auth.terminal=true` | +| legacy `env_var` | 首轮不新增凭证表单;显示为 unsupported,并引导使用现有 manual env override | +| `terminal` | 直接运行当前连接的同一 materialized command/base args,加上 method args/env;exit 0 后重连并重新 initialize;不得把 terminal method ID 传给 `authenticate` | `logout` 只在 `agentCapabilities.auth.logout` 存在时启用。logout 成功后关闭或失效当前 ACP session handle,避免继续使用旧认证上下文。 diff --git a/docs/features/acp-v1-reliability/spec.md b/docs/features/acp-v1-reliability/spec.md index b1a7f36436..01bc6eaa82 100644 --- a/docs/features/acp-v1-reliability/spec.md +++ b/docs/features/acp-v1-reliability/spec.md @@ -4,6 +4,8 @@ > reliability scope remains open until the task ledger is reconciled against current code and real-agent > validation. Canonical ownership now places direct `kind=acp` execution in `AcpAgentRuntime` / > `AcpAgentInstance`; `AcpProvider` is retained only for `kind=deepchat + providerId=acp` compatibility. +> Terminal authentication is specified separately in `docs/features/acp-terminal-auth/spec.md`; that +> focused specification is authoritative where this broad reliability document conflicts with it. Last reviewed: 2026-06-02 @@ -60,7 +62,7 @@ DeepChat 已经具备 ACP agent 的基本启动、初始化、`session/new`、`s | --- | --- | --- | --- | | Transports | ACP 使用 JSON-RPC 2.0;常见 client 以 agent subprocess + stdio 通信;MCP stdio 必须支持,HTTP/SSE 按 agent capability 过滤 | 已有 subprocess/stdout/stderr 连接、registry launch spec、MCP transport filter;需要加强版本漂移和进程树清理 | registry launch spec 仍为首选;global/local 命令只做 fallback/diagnostics;初始化、认证、E2E probe 都有 timeout 和 process tree cleanup | | Initialization | Client 调 `initialize`,发送 `protocolVersion`、`clientCapabilities`、`clientInfo`;Agent 返回 `agentCapabilities`、`authMethods`、`agentInfo` | 已发送 `fs`、`terminal`;未声明/实现 auth capability;只解析部分 capability | 解析并保存完整 capability snapshot;不支持协议版本时关闭连接并展示错误;只声明已实现 client capabilities | -| Authentication | Agent 用 `authMethods` 暴露方法;Client 调 `authenticate({ methodId })`;`logout` 只能在 `agentCapabilities.auth.logout` 存在时调用 | 有 auth method 日志字段,但没有产品化 authenticate/logout 入口 | 增加 authenticate/logout typed route/debug/UI 入口;处理 `agent`、`env_var`、`terminal` 类型;auth required 错误转成可操作状态 | +| Authentication | Agent 用 `authMethods` 暴露方法;默认 `agent` 方法走 `authenticate({ methodId })`;Preview `terminal` 方法走独立交互进程,成功后重连且不得调用 `authenticate`;`logout` 只能在 `agentCapabilities.auth.logout` 存在时调用 | 有 auth method 日志字段,但没有产品化 authenticate/logout/terminal auth 入口 | 按 `docs/features/acp-terminal-auth/spec.md` 实现 terminal auth;增加 agent authenticate/logout typed route/debug/UI 入口;auth required 错误转成可操作状态 | | Session Setup: `session/new` | 创建新 session,传 `cwd` 和 MCP servers,返回 `sessionId`,可带初始 modes/models/config options | 已支持;但 listener 通常在返回后注册,早期 update 可能丢 | 新 DeepChat 会话首次使用 ACP agent 时才创建远端 session;返回后写入本地 `AcpSessionLink`;缓冲并 flush 早期 update | | Session Setup: `session/load` | 仅 `loadSession=true` 时调用;agent 会重放历史 update,再响应 load 完成 | 已支持并在 load 前注册 listener | 用作远端 session 历史导入/重放;进入 staging buffer,转换为 DeepChat message/block 后按 fingerprint 幂等落库 | | Session Setup: `session/resume` | 仅 `sessionCapabilities.resume` 存在时调用;不重放历史,恢复上下文后返回 | 未接入 | 用于已绑定 DeepChat conversation 的继续对话;不把远端 session 当事实源覆盖本地消息 | diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index 2da17d8e18..60c8dc6485 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -108,7 +108,7 @@ { "id": "claude-acp", "name": "Claude Agent", - "version": "0.69.0", + "version": "0.70.0", "description": "ACP wrapper for Anthropic's Claude", "repository": "https://github.com/agentclientprotocol/claude-agent-acp", "authors": [ @@ -119,7 +119,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@agentclientprotocol/claude-agent-acp@0.69.0" + "package": "@agentclientprotocol/claude-agent-acp@0.70.0" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" @@ -168,7 +168,7 @@ { "id": "codex-acp", "name": "Codex", - "version": "1.4.0", + "version": "1.6.0", "description": "ACP adapter for OpenAI's coding assistant", "repository": "https://github.com/agentclientprotocol/codex-acp", "authors": [ @@ -179,7 +179,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@agentclientprotocol/codex-acp@1.4.0" + "package": "@agentclientprotocol/codex-acp@1.6.0" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codex-acp.svg" @@ -472,7 +472,7 @@ { "id": "dimcode", "name": "DimCode", - "version": "0.3.13", + "version": "0.3.16", "description": "A coding agent that puts leading models at your command.", "website": "https://dimcode.dev/docs/acp.html", "authors": [ @@ -481,7 +481,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "dimcode@0.3.13", + "package": "dimcode@0.3.16", "args": [ "acp" ] @@ -492,7 +492,7 @@ { "id": "dirac", "name": "Dirac", - "version": "0.4.36", + "version": "0.4.37", "description": "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", "repository": "https://github.com/dirac-run/dirac", "website": "https://dirac.run", @@ -503,7 +503,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dirac.svg", "distribution": { "npx": { - "package": "dirac-cli@0.4.36", + "package": "dirac-cli@0.4.37", "args": [ "--acp" ] @@ -513,7 +513,7 @@ { "id": "factory-droid", "name": "Factory Droid", - "version": "0.197.0", + "version": "0.200.0", "description": "Factory Droid - AI coding agent powered by Factory AI", "website": "https://factory.ai/product/cli", "authors": [ @@ -522,7 +522,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "droid@0.197.0", + "package": "droid@0.200.0", "args": [ "exec", "--output-format", @@ -563,7 +563,7 @@ { "id": "gemini", "name": "Gemini CLI", - "version": "0.55.1", + "version": "0.56.0", "description": "Google's official CLI for Gemini", "repository": "https://github.com/google-gemini/gemini-cli", "website": "https://geminicli.com", @@ -573,7 +573,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@google/gemini-cli@0.55.1", + "package": "@google/gemini-cli@0.56.0", "args": [ "--acp" ] @@ -605,7 +605,7 @@ { "id": "glm-acp-agent", "name": "GLM Agent", - "version": "1.5.0", + "version": "1.6.0", "description": "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", "repository": "https://github.com/stefandevo/glm-acp-agent", "authors": [ @@ -615,7 +615,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/glm-acp-agent.svg", "distribution": { "npx": { - "package": "glm-acp-agent@1.5.0" + "package": "glm-acp-agent@1.6.0" } } }, @@ -679,7 +679,7 @@ { "id": "grok-build", "name": "Grok Build", - "version": "1.0.5", + "version": "1.0.7", "description": "xAI's coding agent and CLI", "website": "https://x.ai/cli", "authors": [ @@ -688,7 +688,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@xai-official/grok@1.0.5", + "package": "@xai-official/grok@1.0.7", "args": [ "agent", "stdio" @@ -700,7 +700,7 @@ { "id": "harn", "name": "Harn", - "version": "0.10.103", + "version": "0.10.105", "description": "Harn runs .harn agent pipelines as a native ACP coding agent over stdio.", "repository": "https://github.com/burin-labs/harn", "website": "https://harnlang.com", @@ -711,49 +711,49 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.103/harn-aarch64-apple-darwin.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.105/harn-aarch64-apple-darwin.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "366150192837328364be7299f0765ac8938923115277a68b34dcc7e906a6f228" + "sha256": "fa3145e91d15f980416f39577c95b9e13e800c83e06e4463ea529dd9e9b07e54" }, "darwin-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.103/harn-x86_64-apple-darwin.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.105/harn-x86_64-apple-darwin.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "d64b9248ea1b80fc184c9a41ac2e2ecac341aa11299c6e634957e7fa0546f425" + "sha256": "cb713b22e46984ef6385db25f57e6ad155a8063436f5cd2dacc9c9ed3c4fad00" }, "linux-aarch64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.103/harn-aarch64-unknown-linux-gnu.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.105/harn-aarch64-unknown-linux-gnu.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "64ff3424142e24df7838f23bab8ccaacabf547685ad1edefae5ed56668b76577" + "sha256": "57b5a343045436aea1497615c38e31d2750c3970a92ad64251896537b0ba970f" }, "linux-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.103/harn-x86_64-unknown-linux-gnu.tar.gz", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.105/harn-x86_64-unknown-linux-gnu.tar.gz", "cmd": "./harn", "args": [ "serve", "acp" ], - "sha256": "9c1a4c74c47c9146b5ac6360fb2554fdcfa717d1c0be450dc42f26144b61bbdd" + "sha256": "82ba08dab746f383b28a312dde2cf29fbe9181ba89c9571fd6eb77cb851497cb" }, "windows-x86_64": { - "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.103/harn-x86_64-pc-windows-msvc.zip", + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.105/harn-x86_64-pc-windows-msvc.zip", "cmd": "harn.exe", "args": [ "serve", "acp" ], - "sha256": "06122e148c8155b35c33d5839049337bfe556cb7731b21a6b2dfb76fc20592df" + "sha256": "2d6b574b1b6267a94c842281fcfdd5d2e9b4b96a7b73825a38ce6e8f68072523" } } }, @@ -762,7 +762,7 @@ { "id": "junie", "name": "Junie", - "version": "2783.5.0", + "version": "2913.6.0", "description": "AI Coding Agent by JetBrains", "repository": "https://github.com/JetBrains/junie-acp-release", "website": "https://junie.jetbrains.com", @@ -773,42 +773,42 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2783.5/junie-release-2783.5-macos-aarch64.zip", + "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2913.6/junie-release-2913.6-macos-aarch64.zip", "cmd": "./Applications/junie.app/Contents/MacOS/junie", "args": [ "--acp=true" ] }, "darwin-x86_64": { - "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2783.5/junie-release-2783.5-macos-amd64.zip", + "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2913.6/junie-release-2913.6-macos-amd64.zip", "cmd": "./Applications/junie.app/Contents/MacOS/junie", "args": [ "--acp=true" ] }, "linux-aarch64": { - "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2783.5/junie-release-2783.5-linux-aarch64.zip", + "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2913.6/junie-release-2913.6-linux-aarch64.zip", "cmd": "./junie-app/bin/junie", "args": [ "--acp=true" ] }, "linux-x86_64": { - "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2783.5/junie-release-2783.5-linux-amd64.zip", + "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2913.6/junie-release-2913.6-linux-amd64.zip", "cmd": "./junie-app/bin/junie", "args": [ "--acp=true" ] }, "windows-x86_64": { - "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2783.5/junie-release-2783.5-windows-amd64.zip", + "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2913.6/junie-release-2913.6-windows-amd64.zip", "cmd": "./junie/junie.exe", "args": [ "--acp=true" ] }, "windows-aarch64": { - "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2783.5/junie-release-2783.5-windows-aarch64.zip", + "archive": "https://github.com/JetBrains/junie-acp-release/releases/download/2913.6/junie-release-2913.6-windows-aarch64.zip", "cmd": "./junie/junie.exe", "args": [ "--acp=true" @@ -1191,7 +1191,7 @@ { "id": "qwen-code", "name": "Qwen Code", - "version": "0.21.13", + "version": "0.21.14", "description": "Alibaba's Qwen coding assistant", "repository": "https://github.com/QwenLM/qwen-code", "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", @@ -1201,7 +1201,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@qwen-code/qwen-code@0.21.13", + "package": "@qwen-code/qwen-code@0.21.14", "args": [ "--acp", "--experimental-skills" diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 245b468c3b..8dc5dc90dd 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -4819,46 +4819,6 @@ "last_updated": "2026-07-16", "type": "chat" }, - { - "id": "umans-glm-5.1", - "name": "GLM 5.1", - "display_name": "GLM 5.1", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 131072 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", - "type": "chat" - }, { "id": "umans-qwen3.6-35b-a3b", "name": "Qwen3.6 35B A3B", @@ -13366,7 +13326,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -13396,7 +13357,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -13426,7 +13388,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -13456,7 +13419,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -13514,7 +13478,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -13695,7 +13660,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -13764,7 +13730,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -13988,7 +13955,8 @@ "input": [ "text", "image", - "audio" + "audio", + "pdf" ], "output": [ "text" @@ -14051,7 +14019,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -14357,7 +14326,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -14392,7 +14362,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -14573,7 +14544,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -14886,7 +14858,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -15026,7 +14999,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -15253,7 +15227,8 @@ "input": [ "text", "image", - "audio" + "audio", + "pdf" ], "output": [ "text" @@ -15385,7 +15360,8 @@ "input": [ "text", "image", - "audio" + "audio", + "pdf" ], "output": [ "text" @@ -15650,7 +15626,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -15708,7 +15685,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -15826,7 +15804,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -15907,7 +15886,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -16044,7 +16024,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -16089,6 +16070,35 @@ "last_updated": "2026-03-05", "type": "chat" }, + { + "id": "qwen3.8-27b", + "name": "Qwen3.8 27B", + "display_name": "Qwen3.8 27B", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "gpt-oss-safeguard-120b", "name": "GPT OSS Safeguard 120B", @@ -16124,7 +16134,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -16194,7 +16205,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -16289,7 +16301,8 @@ "display_name": "Mixtral 8x7B Instruct v0.1", "modalities": { "input": [ - "text" + "text", + "pdf" ], "output": [ "text" @@ -16305,7 +16318,7 @@ "supported": true, "default": true }, - "attachment": false, + "attachment": true, "open_weights": true, "knowledge": "2023-09", "release_date": "2023-12-11", @@ -16348,7 +16361,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -16523,7 +16537,8 @@ "display_name": "mistral-7b-instruct-v0.2", "modalities": { "input": [ - "text" + "text", + "pdf" ], "output": [ "text" @@ -16538,7 +16553,7 @@ "reasoning": { "supported": false }, - "attachment": false, + "attachment": true, "open_weights": false, "release_date": "2025-05-26", "last_updated": "2025-05-26", @@ -16551,7 +16566,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -16636,7 +16652,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -16664,7 +16681,8 @@ "display_name": "mistral-large-2402", "modalities": { "input": [ - "text" + "text", + "pdf" ], "output": [ "text" @@ -16680,7 +16698,7 @@ "supported": true, "default": true }, - "attachment": false, + "attachment": true, "open_weights": false, "release_date": "2025-05-26", "last_updated": "2025-05-26", @@ -16789,7 +16807,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -16931,7 +16950,8 @@ "input": [ "text", "image", - "audio" + "audio", + "pdf" ], "output": [ "text" @@ -16980,7 +17000,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -17054,7 +17075,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -17574,32 +17596,6 @@ "last_updated": "2026-07-25", "type": "chat" }, - { - "id": "doubao-seed-1-8-251215", - "name": "Doubao Seed 1.8", - "display_name": "Doubao Seed 1.8", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 128000, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "release_date": "2024-01-01", - "last_updated": "2025-12-15", - "type": "chat" - }, { "id": "qwen3-coder-30b-a3b-instruct", "name": "Qwen3 Coder 30B A3B Instruct", @@ -18980,6 +18976,7 @@ "context": 32768, "output": 32768 }, + "temperature": true, "tool_call": false, "reasoning": { "supported": true, @@ -18998,8 +18995,9 @@ }, "attachment": true, "open_weights": true, - "release_date": "2025-08-26", - "last_updated": "2025-08-26", + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", "type": "chat" }, { @@ -20590,33 +20588,6 @@ "last_updated": "2025-07-09", "type": "chat" }, - { - "id": "mirothinker-1-7-deepresearch", - "name": "MiroThinker 1.7 Deep Research", - "display_name": "MiroThinker 1.7 Deep Research", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 262144, - "output": 16384 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "release_date": "2026-05-11", - "last_updated": "2026-05-11", - "type": "chat" - }, { "id": "claude-sonnet-4-thinking:64000", "name": "Claude 4 Sonnet Thinking (64K)", @@ -20848,32 +20819,6 @@ "last_updated": "2026-02-14", "type": "chat" }, - { - "id": "step-2-mini", - "name": "Step-2 Mini", - "display_name": "Step-2 Mini", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 8000, - "output": 4096 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "release_date": "2024-10-15", - "last_updated": "2024-07-05", - "type": "chat" - }, { "id": "exa-answer", "name": "Exa (Answer)", @@ -21845,33 +21790,6 @@ "last_updated": "2026-08-16", "type": "chat" }, - { - "id": "step-3", - "name": "Step-3", - "display_name": "Step-3", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 65536, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": true, - "open_weights": true, - "release_date": "2025-07-31", - "last_updated": "2025-07-31", - "type": "chat" - }, { "id": "deepseek-reasoner", "name": "DeepSeek Reasoner", @@ -22063,32 +21981,6 @@ "last_updated": "2025-05-20", "type": "chat" }, - { - "id": "step-2-16k-exp", - "name": "Step-2 16k Exp", - "display_name": "Step-2 16k Exp", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 16000, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "release_date": "2024-10-15", - "last_updated": "2024-07-05", - "type": "chat" - }, { "id": "qvq-max", "name": "Qwen: QvQ Max", @@ -22899,33 +22791,6 @@ "last_updated": "2025-05-22", "type": "chat" }, - { - "id": "mirothinker-1-7-deepresearch-mini", - "name": "MiroThinker 1.7 Deep Research Mini", - "display_name": "MiroThinker 1.7 Deep Research Mini", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 262144, - "output": 16384 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "release_date": "2026-05-11", - "last_updated": "2026-05-11", - "type": "chat" - }, { "id": "qwen3.5-omni-plus", "name": "Qwen3.5 Omni Plus", @@ -31720,14 +31585,16 @@ "context": 128000, "output": 262144 }, + "temperature": true, "tool_call": false, "reasoning": { "supported": false }, "attachment": true, "open_weights": true, - "release_date": "2024-01-01", - "last_updated": "2024-01-01", + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", "type": "chat" }, { @@ -35656,8 +35523,7 @@ "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" @@ -35893,39 +35759,6 @@ "last_updated": "2026-04-07", "type": "chat" }, - { - "id": "TEE/deepseek-v4-pro-0813", - "name": "DeepSeek V4 Pro 0813 TEE", - "display_name": "DeepSeek V4 Pro 0813 TEE", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 1048576 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "attachment": false, - "open_weights": false, - "release_date": "2026-08-12", - "last_updated": "2026-08-12", - "type": "chat" - }, { "id": "TEE/deepseek-v3.2", "name": "DeepSeek V3.2 TEE", @@ -36496,34 +36329,6 @@ "last_updated": "2026-04-21", "type": "chat" }, - { - "id": "TEE/deepseek-v4-pro-0813:thinking", - "name": "DeepSeek V4 Pro 0813 Thinking TEE", - "display_name": "DeepSeek V4 Pro 0813 Thinking TEE", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 1048576 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "release_date": "2026-08-12", - "last_updated": "2026-08-12", - "type": "chat" - }, { "id": "dots-studio/dots-3-note-preview", "name": "Dots3-Note Preview", @@ -37392,17 +37197,17 @@ } ] }, - "abliteration-ai": { - "id": "abliteration-ai", - "name": "abliteration.ai", - "display_name": "abliteration.ai", - "api": "https://api.abliteration.ai/v1", - "doc": "https://docs.abliteration.ai/models", + "jalapeno": { + "id": "jalapeno", + "name": "Jalapeno Cloud", + "display_name": "Jalapeno Cloud", + "api": "https://api.jalapeno-cloud.ai/v1", + "doc": "https://www.jalapeno-cloud.ai/docs/", "models": [ { - "id": "abliterated-model-large", - "name": "Abliterated Model Large", - "display_name": "Abliterated Model Large", + "id": "DeepSeek-V4-Pro", + "name": "DeepSeek V4 Pro", + "display_name": "DeepSeek V4 Pro", "modalities": { "input": [ "text" @@ -37412,8 +37217,8 @@ ] }, "limit": { - "context": 1000000, - "output": 999990 + "context": 1048576, + "output": 384000 }, "temperature": true, "tool_call": true, @@ -37421,65 +37226,92 @@ "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "The DeepSeek API maps low to high and xhigh to max for V4 Pro; effort_options lists distinct model-effective levels." + ] + } + }, "attachment": false, - "open_weights": false, - "release_date": "2026-07-25", - "last_updated": "2026-07-28", + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", "type": "chat" }, { - "id": "abliterated-model", - "name": "Abliterated Model", - "display_name": "Abliterated Model", + "id": "Kimi-K2.5", + "name": "Kimi K2.5", + "display_name": "Kimi K2.5", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 150000, - "output": 8192 + "context": 262144, + "output": 180224 }, - "temperature": true, + "temperature": false, "tool_call": true, "reasoning": { "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, "attachment": true, "open_weights": true, - "release_date": "2026-01-06", - "last_updated": "2026-07-28", + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-01", "type": "chat" - } - ] - }, - "deepseek": { - "id": "deepseek", - "name": "DeepSeek", - "display_name": "DeepSeek", - "api": "https://api.deepseek.com", - "doc": "https://api-docs.deepseek.com/quick_start/pricing", - "models": [ + }, { - "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "display_name": "DeepSeek V4 Flash", + "id": "Qwen3-VL-235B-A22B-Thinking", + "name": "Qwen3 VL 235B A22B Thinking", + "display_name": "Qwen3 VL 235B A22B Thinking", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 384000 + "context": 131072, + "output": 32768 }, "temperature": true, "tool_call": true, @@ -37490,124 +37322,796 @@ "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "high", - "effort_options": [ - "low", - "high", - "max" - ], "interleaved": true, "summaries": true, "visibility": "summary", "continuation": [ "thinking_blocks" - ], - "notes": [ - "The DeepSeek API maps xhigh to high for V4 Flash; effort_options lists distinct model-effective levels." ] } }, - "attachment": false, + "attachment": true, "open_weights": true, - "knowledge": "2025-05", - "release_date": "2026-07-31", - "last_updated": "2026-07-31", + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", "type": "chat" }, { - "id": "deepseek-v4-pro", - "name": "DeepSeek V4 Pro", - "display_name": "DeepSeek V4 Pro", + "id": "Qwen3-VL-235B-A22B-Instruct", + "name": "Qwen3 VL 235B A22B Instruct", + "display_name": "Qwen3 VL 235B A22B Instruct", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 384000 + "context": 129024, + "output": 32768 }, "temperature": true, "tool_call": true, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "high", - "effort_options": [ - "high", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "The DeepSeek API maps low to high and xhigh to max for V4 Pro; effort_options lists distinct model-effective levels." - ] - } + "supported": false }, - "attachment": false, - "open_weights": false, - "release_date": "2026-08-12", - "last_updated": "2026-08-12", + "attachment": true, + "open_weights": true, + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", "type": "chat" }, { - "id": "deepseek-chat", - "name": "DeepSeek Chat", - "display_name": "DeepSeek Chat", + "id": "Qwen3.5-122B-A10B", + "name": "Qwen3.5 122B-A10B", + "display_name": "Qwen3.5 122B-A10B", "modalities": { "input": [ - "text" + "text", + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 384000 + "context": 262144, + "output": 65536 }, "temperature": true, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "attachment": true, "open_weights": true, - "knowledge": "2025-09", - "release_date": "2025-12-01", - "last_updated": "2026-02-28", + "release_date": "2026-02-23", + "last_updated": "2026-02-23", "type": "chat" }, { - "id": "deepseek-reasoner", - "name": "DeepSeek Reasoner", - "display_name": "DeepSeek Reasoner", + "id": "MiniMax-M3", + "name": "MiniMax-M3", + "display_name": "MiniMax-M3", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 384000 + "context": 524288, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "type": "chat" + }, + { + "id": "GLM-5.1", + "name": "GLM-5.1", + "display_name": "GLM-5.1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 202752, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "type": "chat" + }, + { + "id": "Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 271360, + "output": 262144 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "type": "chat" + }, + { + "id": "Qwen3.5-27B", + "name": "Qwen3.5 27B", + "display_name": "Qwen3.5 27B", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "type": "chat" + }, + { + "id": "Qwen3.5-35B-A3B", + "name": "Qwen3.5 35B-A3B", + "display_name": "Qwen3.5 35B-A3B", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "type": "chat" + }, + { + "id": "Qwen3-Next-80B-A3B-Thinking", + "name": "Qwen3-Next 80B-A3B (Thinking)", + "display_name": "Qwen3-Next 80B-A3B (Thinking)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", + "type": "chat" + }, + { + "id": "Qwen3.5-397B-A17B", + "name": "Qwen3.5 397B-A17B", + "display_name": "Qwen3.5 397B-A17B", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-15", + "last_updated": "2026-02-15", + "type": "chat" + }, + { + "id": "Kimi-K3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "type": "chat" + }, + { + "id": "Hy3", + "name": "Hy3", + "display_name": "Hy3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 202752, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-07-06", + "last_updated": "2026-07-06", + "type": "chat" + }, + { + "id": "GLM-5.2", + "name": "GLM-5.2", + "display_name": "GLM-5.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "type": "chat" + }, + { + "id": "DeepSeek-V4-Flash", + "name": "DeepSeek V4 Flash", + "display_name": "DeepSeek V4 Flash", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "The DeepSeek API maps xhigh to high for V4 Flash; effort_options lists distinct model-effective levels." + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "type": "chat" + }, + { + "id": "Qwen3-Next-80B-A3B-Instruct", + "name": "Qwen3-Next 80B-A3B Instruct", + "display_name": "Qwen3-Next 80B-A3B Instruct", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 129024, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-09", + "last_updated": "2025-09", + "type": "chat" + } + ] + }, + "abliteration-ai": { + "id": "abliteration-ai", + "name": "abliteration.ai", + "display_name": "abliteration.ai", + "api": "https://api.abliteration.ai/v1", + "doc": "https://docs.abliteration.ai/models", + "models": [ + { + "id": "abliterated-model-large", + "name": "Abliterated Model Large", + "display_name": "Abliterated Model Large", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 999990 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-07-25", + "last_updated": "2026-07-28", + "type": "chat" + }, + { + "id": "abliterated-model", + "name": "Abliterated Model", + "display_name": "Abliterated Model", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 150000, + "output": 8192 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-01-06", + "last_updated": "2026-07-28", + "type": "chat" + } + ] + }, + "deepseek": { + "id": "deepseek", + "name": "DeepSeek", + "display_name": "DeepSeek", + "api": "https://api.deepseek.com", + "doc": "https://api-docs.deepseek.com/quick_start/pricing", + "models": [ + { + "id": "deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "display_name": "DeepSeek V4 Flash", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "The DeepSeek API maps xhigh to high for V4 Flash; effort_options lists distinct model-effective levels." + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", + "type": "chat" + }, + { + "id": "deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "display_name": "DeepSeek V4 Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "The DeepSeek API maps low to high and xhigh to max for V4 Pro; effort_options lists distinct model-effective levels." + ] + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, + { + "id": "deepseek-chat", + "name": "DeepSeek Chat", + "display_name": "DeepSeek Chat", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-09", + "release_date": "2025-12-01", + "last_updated": "2026-02-28", + "type": "chat" + }, + { + "id": "deepseek-reasoner", + "name": "DeepSeek Reasoner", + "display_name": "DeepSeek Reasoner", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 }, "temperature": true, "tool_call": true, @@ -45993,6 +46497,35 @@ "last_updated": "2025-07-21", "type": "chat" }, + { + "id": "Qwen/Qwen3-VL-235B-A22B-Instruct", + "name": "Qwen3 VL 235B A22B Instruct", + "display_name": "Qwen3 VL 235B A22B Instruct", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "type": "chat" + }, { "id": "Qwen/Qwen3.6-27B", "name": "Qwen3.6 27B", @@ -46531,6 +47064,35 @@ "last_updated": "2026-08-12", "type": "chat" }, + { + "id": "Qwen/Qwen3.8-27B", + "name": "Qwen3.8 27B", + "display_name": "Qwen3.8 27B", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "Qwen/Qwen3-Next-80B-A3B-Instruct", "name": "Qwen3-Next 80B-A3B Instruct", @@ -48207,9 +48769,9 @@ "type": "chat" }, { - "id": "Qwen/Qwen3.6-27B", - "name": "Qwen3.6 27B", - "display_name": "Qwen3.6 27B", + "id": "Qwen/Qwen3-VL-235B-A22B-Thinking", + "name": "Qwen3 VL 235B A22B Thinking", + "display_name": "Qwen3 VL 235B A22B Thinking", "modalities": { "input": [ "text", @@ -48220,8 +48782,8 @@ ] }, "limit": { - "context": 262144, - "output": 65536 + "context": 131072, + "output": 32768 }, "temperature": true, "tool_call": true, @@ -48242,14 +48804,15 @@ }, "attachment": true, "open_weights": true, - "release_date": "2026-04-22", - "last_updated": "2026-04-22", + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", "type": "chat" }, { - "id": "Qwen/Qwen3.5-9B", - "name": "Qwen3.5 9B", - "display_name": "Qwen3.5 9B", + "id": "Qwen/Qwen3-VL-235B-A22B-Instruct", + "name": "Qwen3 VL 235B A22B Instruct", + "display_name": "Qwen3 VL 235B A22B Instruct", "modalities": { "input": [ "text", @@ -48260,36 +48823,25 @@ ] }, "limit": { - "context": 262144, - "output": 65536 + "context": 131072, + "output": 32768 }, "temperature": true, "tool_call": true, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "attachment": true, "open_weights": true, - "release_date": "2026-02-23", - "last_updated": "2026-02-23", + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", "type": "chat" }, { - "id": "Qwen/Qwen3.5-122B-A10B", - "name": "Qwen3.5 122B-A10B", - "display_name": "Qwen3.5 122B-A10B", + "id": "Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "display_name": "Qwen3.6 27B", "modalities": { "input": [ "text", @@ -48322,150 +48874,14 @@ }, "attachment": true, "open_weights": true, - "release_date": "2026-02-23", - "last_updated": "2026-02-23", - "type": "chat" - }, - { - "id": "Qwen/Qwen3-32B", - "name": "Qwen3 32B", - "display_name": "Qwen3 32B", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 16384 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-04", - "release_date": "2025-04", - "last_updated": "2025-04", - "type": "chat" - }, - { - "id": "Qwen/Qwen3-235B-A22B", - "name": "Qwen3 235B-A22B", - "display_name": "Qwen3 235B-A22B", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 40960, - "output": 16384 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-04", - "release_date": "2025-04", - "last_updated": "2025-04", - "type": "chat" - }, - { - "id": "Qwen/Qwen3-Coder-Next", - "name": "Qwen3-Coder-Next", - "display_name": "Qwen3-Coder-Next", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 262144, - "output": 65536 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-04", - "release_date": "2026-02-03", - "last_updated": "2026-02-03", - "type": "chat" - }, - { - "id": "Qwen/Qwen3-Coder-30B-A3B-Instruct", - "name": "Qwen3-Coder 30B-A3B Instruct", - "display_name": "Qwen3-Coder 30B-A3B Instruct", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 262144, - "output": 65536 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-04", - "release_date": "2025-04", - "last_updated": "2025-04", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "type": "chat" }, { - "id": "Qwen/Qwen3.5-27B", - "name": "Qwen3.5 27B", - "display_name": "Qwen3.5 27B", + "id": "Qwen/Qwen3.5-9B", + "name": "Qwen3.5 9B", + "display_name": "Qwen3.5 9B", "modalities": { "input": [ "text", @@ -48503,9 +48919,225 @@ "type": "chat" }, { - "id": "Qwen/Qwen3.5-35B-A3B", - "name": "Qwen3.5 35B-A3B", - "display_name": "Qwen3.5 35B-A3B", + "id": "Qwen/Qwen3.5-122B-A10B", + "name": "Qwen3.5 122B-A10B", + "display_name": "Qwen3.5 122B-A10B", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "type": "chat" + }, + { + "id": "Qwen/Qwen3-32B", + "name": "Qwen3 32B", + "display_name": "Qwen3 32B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 16384 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", + "type": "chat" + }, + { + "id": "Qwen/Qwen3-235B-A22B", + "name": "Qwen3 235B-A22B", + "display_name": "Qwen3 235B-A22B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 40960, + "output": 16384 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", + "type": "chat" + }, + { + "id": "Qwen/Qwen3-Coder-Next", + "name": "Qwen3-Coder-Next", + "display_name": "Qwen3-Coder-Next", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2026-02-03", + "last_updated": "2026-02-03", + "type": "chat" + }, + { + "id": "Qwen/Qwen3-Coder-30B-A3B-Instruct", + "name": "Qwen3-Coder 30B-A3B Instruct", + "display_name": "Qwen3-Coder 30B-A3B Instruct", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", + "type": "chat" + }, + { + "id": "Qwen/Qwen3.5-27B", + "name": "Qwen3.5 27B", + "display_name": "Qwen3.5 27B", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "type": "chat" + }, + { + "id": "Qwen/Qwen3.5-35B-A3B", + "name": "Qwen3.5 35B-A3B", + "display_name": "Qwen3.5 35B-A3B", "modalities": { "input": [ "text", @@ -48913,6 +49545,35 @@ "last_updated": "2025-01-01", "type": "embedding" }, + { + "id": "zai-org/GLM-4.6V-Flash", + "name": "GLM-4.6V-Flash", + "display_name": "GLM-4.6V-Flash", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2025-12-08", + "last_updated": "2025-12-08", + "type": "chat" + }, { "id": "zai-org/GLM-4.5V", "name": "GLM-4.5V", @@ -52844,43 +53505,9 @@ "doc": "https://experiments.hetzner.com/docs/inference", "models": [ { - "id": "DeepSeek-V4-Flash-0731", - "name": "DeepSeek V4 Flash 0731", - "display_name": "DeepSeek V4 Flash 0731", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 512000, - "output": 384000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-05", - "release_date": "2026-07-31", - "last_updated": "2026-07-31", - "type": "chat" - }, - { - "id": "Kimi-K2.7-Code", - "name": "Kimi K2.7 Code", - "display_name": "Kimi K2.7 Code", + "id": "Qwen3.8-27B", + "name": "Qwen3.8-27B", + "display_name": "Qwen3.8-27B", "modalities": { "input": [ "text", @@ -52894,50 +53521,16 @@ "context": 262144, "output": 262144 }, - "temperature": false, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "attachment": true, - "open_weights": true, - "knowledge": "2025-01", - "release_date": "2026-06-12", - "last_updated": "2026-06-12", - "type": "chat" - }, - { - "id": "GLM-5.2-NVFP4", - "name": "GLM-5.2 NVFP4", - "display_name": "GLM-5.2 NVFP4", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 512000, - "output": 131072 - }, "temperature": true, "tool_call": true, "reasoning": { "supported": true, "default": true }, - "attachment": false, + "attachment": true, "open_weights": true, - "release_date": "2026-06-13", - "last_updated": "2026-06-13", + "release_date": "2026-08-14", + "last_updated": "2026-08-14", "type": "chat" }, { @@ -53787,87 +54380,6 @@ } ] }, - "scx": { - "id": "scx", - "name": "SCX.ai", - "display_name": "SCX.ai", - "api": "https://api.scx.ai/v1", - "doc": "https://platform.scx.ai/docs", - "models": [ - { - "id": "MiniMax-M2.7", - "name": "MiniMax-M2.7", - "display_name": "MiniMax-M2.7", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 192000, - "output": 64000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "release_date": "2026-03-18", - "last_updated": "2026-03-18", - "type": "chat" - }, - { - "id": "gpt-oss-120b", - "name": "GPT OSS 120B", - "display_name": "GPT OSS 120B", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 131072 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "attachment": false, - "open_weights": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", - "type": "chat" - } - ] - }, "auriko": { "id": "auriko", "name": "Auriko", @@ -55534,6 +56046,39 @@ "last_updated": "2026-03-16", "type": "chat" }, + { + "id": "z-ai/glm-5.3", + "name": "GLM-5.3", + "display_name": "GLM-5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "z-ai/glm-5.2", "name": "GLM-5.2", @@ -68211,6 +68756,55 @@ } ] }, + "kosmik": { + "id": "kosmik", + "name": "Kosmik Compute", + "display_name": "Kosmik Compute", + "api": "https://api.koscompute.com/v1", + "doc": "https://api.koscompute.com/docs/", + "models": [ + { + "id": "qwen/qwen3.8-27b", + "name": "Qwen3.8 27B", + "display_name": "Qwen3.8 27B", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + } + ] + }, "minimax-cn-coding-plan": { "id": "minimax-cn-coding-plan", "name": "MiniMax Token Plan (minimaxi.com)", @@ -70370,6 +70964,36 @@ "last_updated": "2026-05-28", "type": "chat" }, + { + "id": "xai.grok-4.6", + "name": "Grok 4.6", + "display_name": "Grok 4.6", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 500000, + "output": 500000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-01", + "release_date": "2026-08-12", + "last_updated": "2026-08-18", + "type": "chat" + }, { "id": "anthropic.claude-sonnet-4-6", "name": "Claude Sonnet 4.6", @@ -76781,8 +77405,8 @@ }, { "id": "gpt-5.6-luna", - "name": "GPT-5.6 Luna (2x usage)", - "display_name": "GPT-5.6 Luna (2x usage)", + "name": "GPT-5.6 Luna", + "display_name": "GPT-5.6 Luna", "modalities": { "input": [ "text", @@ -77380,8 +78004,8 @@ }, { "id": "hy3", - "name": "Hy3", - "display_name": "Hy3", + "name": "Hy3 (8x usage)", + "display_name": "Hy3 (8x usage)", "modalities": { "input": [ "text" @@ -77411,6 +78035,38 @@ "last_updated": "2026-07-06", "type": "chat" }, + { + "id": "muse-spark-1.2-contributor", + "name": "Muse Spark 1.2 Contributor", + "display_name": "Muse Spark 1.2 Contributor", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-08-05", + "last_updated": "2026-08-05", + "type": "chat" + }, { "id": "kimi-k2.6", "name": "Kimi K2.6", @@ -79313,6 +79969,45 @@ "last_updated": "2026-04-23", "type": "chat" }, + { + "id": "glm-5.3", + "name": "GLM-5.3", + "display_name": "GLM-5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "gpt-5.2-codex", "name": "GPT-5.2 Codex", @@ -80920,6 +81615,43 @@ } ] }, + "echo": { + "id": "echo", + "name": "Echo", + "display_name": "Echo", + "api": "https://echo.tracerml.ai/v1", + "doc": "https://echo.tracerml.ai/docs/api", + "models": [ + { + "id": "echo", + "name": "Echo", + "display_name": "Echo", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-07-19", + "last_updated": "2026-08-16", + "type": "chat" + } + ] + }, "cloudferro-sherlock": { "id": "cloudferro-sherlock", "name": "CloudFerro Sherlock", @@ -87090,6 +87822,39 @@ "last_updated": "2026-03-16", "type": "chat" }, + { + "id": "zai/glm-5.3", + "name": "GLM-5.3", + "display_name": "GLM-5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "zai/glm-5.2", "name": "GLM-5.2", @@ -89137,6 +89902,47 @@ "last_updated": "2026-02-23", "type": "chat" }, + { + "id": "qwen/qwen3-vl-235b-a22b-thinking", + "name": "Qwen3-VL 235B A22B Thinking", + "display_name": "Qwen3-VL 235B A22B Thinking", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "type": "chat" + }, { "id": "qwen/qwen3-30b-a3b", "name": "Qwen3 30B A3B", @@ -89182,16 +89988,15 @@ "display_name": "Qwen3.5 397B A17B", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "limit": { - "context": 256000, - "output": 64000 + "context": 131072, + "output": 32768 }, "temperature": true, "tool_call": true, @@ -89210,7 +90015,7 @@ ] } }, - "attachment": true, + "attachment": false, "open_weights": true, "release_date": "2026-02-15", "last_updated": "2026-02-15", @@ -89968,6 +90773,35 @@ "last_updated": "2025-07-23", "type": "chat" }, + { + "id": "qwen/qwen3-vl-235b-a22b-instruct", + "name": "Qwen3-VL 235B A22B Instruct", + "display_name": "Qwen3-VL 235B A22B Instruct", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "type": "chat" + }, { "id": "qwen/qwen3.6-flash", "name": "Qwen3.6 Flash", @@ -90007,6 +90841,68 @@ "last_updated": "2026-04-27", "type": "chat" }, + { + "id": "qwen/qwen3.8-2.4t-a95b", + "name": "Qwen3.8 2.4T A95B", + "display_name": "Qwen3.8 2.4T A95B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 1010000 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, + { + "id": "sakana/sakana-namazu", + "name": "Sakana Namazu", + "display_name": "Sakana Namazu", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-08-03", + "last_updated": "2026-08-03", + "type": "chat" + }, { "id": "sakana/fugu-ultra", "name": "Fugu Ultra", @@ -90942,20 +91838,21 @@ "doc": "https://docs.inceptron.io", "models": [ { - "id": "MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax M2.5", - "display_name": "MiniMax M2.5", + "id": "moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 196608, - "output": 196608 + "context": 262144, + "output": 262144 }, "temperature": true, "tool_call": true, @@ -90965,19 +91862,26 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, - "attachment": false, + "attachment": true, "open_weights": true, - "release_date": "2026-02-12", - "last_updated": "2026-02-12", + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "type": "chat" }, { - "id": "moonshotai/Kimi-K2.7-Code", - "name": "Kimi K2.7 Code", - "display_name": "Kimi K2.7 Code", + "id": "moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "display_name": "Kimi K2.6", "modalities": { "input": [ "text", @@ -91011,26 +91915,25 @@ "attachment": true, "open_weights": true, "knowledge": "2025-01", - "release_date": "2026-06-12", - "last_updated": "2026-06-12", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", "type": "chat" }, { - "id": "moonshotai/Kimi-K2.6", - "name": "Kimi K2.6", - "display_name": "Kimi K2.6", + "id": "deepseek-ai/DeepSeek-V4-Flash-0731", + "name": "DeepSeek V4 Flash 0731", + "display_name": "DeepSeek V4 Flash 0731", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "limit": { - "context": 262144, - "output": 262144 + "context": 1048576, + "output": 1048576 }, "temperature": true, "tool_call": true, @@ -91049,57 +91952,17 @@ ] } }, - "attachment": true, + "attachment": false, "open_weights": true, - "knowledge": "2025-01", - "release_date": "2026-04-21", - "last_updated": "2026-04-21", + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-V4-Flash-0731", - "name": "DeepSeek V4 Flash 0731", - "display_name": "DeepSeek V4 Flash 0731", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 1048576 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-05", - "release_date": "2026-07-31", - "last_updated": "2026-07-31", - "type": "chat" - }, - { - "id": "zai-org/GLM-5.2", - "name": "GLM 5.2", - "display_name": "GLM 5.2", + "id": "zai-org/GLM-5.2", + "name": "GLM 5.2", + "display_name": "GLM 5.2", "modalities": { "input": [ "text" @@ -92268,9 +93131,9 @@ "doc": "https://developers.cloudflare.com/ai-gateway/", "models": [ { - "id": "anthropic/claude-opus-4-7", - "name": "Claude Opus 4.7", - "display_name": "Claude Opus 4.7", + "id": "anthropic/claude-opus-4.8", + "name": "Claude Opus 4.8", + "display_name": "Claude Opus 4.8", "modalities": { "input": [ "text", @@ -92319,15 +93182,15 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2026-01-31", - "release_date": "2026-04-14", - "last_updated": "2026-04-16", + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", "type": "chat" }, { - "id": "anthropic/claude-opus-4-8", - "name": "Claude Opus 4.8", - "display_name": "Claude Opus 4.8", + "id": "anthropic/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "display_name": "Claude Sonnet 4.6", "modalities": { "input": [ "text", @@ -92342,7 +93205,7 @@ "context": 1000000, "output": 128000 }, - "temperature": false, + "temperature": true, "tool_call": true, "reasoning": { "supported": true, @@ -92352,33 +93215,35 @@ "reasoning": { "supported": true, "default_enabled": false, - "mode": "effort", + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, "effort": "high", "effort_options": [ "low", "medium", "high", - "xhigh", "max" ], "interleaved": true, "summaries": true, - "visibility": "omitted", + "visibility": "summary", "continuation": [ "thinking_blocks" ], "notes": [ - "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", - "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", - "task_budget is separate from thinking control and should not be treated as a thinking budget." + "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", + "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." ] } }, "attachment": true, "open_weights": false, - "knowledge": "2026-01", - "release_date": "2026-05-28", - "last_updated": "2026-05-28", + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", "type": "chat" }, { @@ -92418,9 +93283,9 @@ "type": "chat" }, { - "id": "anthropic/claude-opus-4-5", - "name": "Claude Opus 4.5 (latest)", - "display_name": "Claude Opus 4.5 (latest)", + "id": "anthropic/claude-haiku-4.5", + "name": "Claude Haiku 4.5 (latest)", + "display_name": "Claude Haiku 4.5 (latest)", "modalities": { "input": [ "text", @@ -92445,17 +93310,11 @@ "reasoning": { "supported": true, "default_enabled": false, - "mode": "mixed", + "mode": "budget", "budget": { "min": 1024, "unit": "tokens" }, - "effort": "high", - "effort_options": [ - "low", - "medium", - "high" - ], "interleaved": true, "summaries": true, "visibility": "summary", @@ -92463,22 +93322,22 @@ "thinking_blocks" ], "notes": [ - "Claude Opus 4.5 uses manual thinking.type = \"enabled\" with budget_tokens; effort can be used alongside the thinking budget.", - "Interleaved thinking requires the interleaved-thinking-2025-05-14 beta header." + "Claude 4 manual thinking uses thinking.type = \"enabled\" with budget_tokens.", + "Interleaved thinking requires the interleaved-thinking-2025-05-14 beta header for this model family." ] } }, "attachment": true, "open_weights": false, - "knowledge": "2025-05", - "release_date": "2025-11-24", - "last_updated": "2025-11-24", + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", "type": "chat" }, { - "id": "anthropic/claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "display_name": "Claude Sonnet 4.6", + "id": "anthropic/claude-opus-4.7", + "name": "Claude Opus 4.7", + "display_name": "Claude Opus 4.7", "modalities": { "input": [ "text", @@ -92493,7 +93352,7 @@ "context": 1000000, "output": 128000 }, - "temperature": true, + "temperature": false, "tool_call": true, "reasoning": { "supported": true, @@ -92503,41 +93362,39 @@ "reasoning": { "supported": true, "default_enabled": false, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, + "mode": "effort", "effort": "high", "effort_options": [ "low", "medium", "high", + "xhigh", "max" ], "interleaved": true, "summaries": true, - "visibility": "summary", + "visibility": "omitted", "continuation": [ "thinking_blocks" ], "notes": [ - "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", - "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." + "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", + "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", + "task_budget is separate from thinking control and should not be treated as a thinking budget." ] } }, "attachment": true, "open_weights": false, - "knowledge": "2025-08-31", - "release_date": "2026-02-17", - "last_updated": "2026-03-13", + "knowledge": "2026-01-31", + "release_date": "2026-04-14", + "last_updated": "2026-04-16", "type": "chat" }, { - "id": "anthropic/claude-sonnet-4-5", - "name": "Claude Sonnet 4.5 (latest)", - "display_name": "Claude Sonnet 4.5 (latest)", + "id": "anthropic/claude-opus-4.5", + "name": "Claude Opus 4.5 (latest)", + "display_name": "Claude Opus 4.5 (latest)", "modalities": { "input": [ "text", @@ -92549,7 +93406,7 @@ ] }, "limit": { - "context": 1000000, + "context": 200000, "output": 64000 }, "temperature": true, @@ -92562,11 +93419,17 @@ "reasoning": { "supported": true, "default_enabled": false, - "mode": "budget", + "mode": "mixed", "budget": { "min": 1024, "unit": "tokens" }, + "effort": "high", + "effort_options": [ + "low", + "medium", + "high" + ], "interleaved": true, "summaries": true, "visibility": "summary", @@ -92574,22 +93437,22 @@ "thinking_blocks" ], "notes": [ - "Claude 4 manual thinking uses thinking.type = \"enabled\" with budget_tokens.", - "Interleaved thinking requires the interleaved-thinking-2025-05-14 beta header for this model family." + "Claude Opus 4.5 uses manual thinking.type = \"enabled\" with budget_tokens; effort can be used alongside the thinking budget.", + "Interleaved thinking requires the interleaved-thinking-2025-05-14 beta header." ] } }, "attachment": true, "open_weights": false, - "knowledge": "2025-07-31", - "release_date": "2025-09-29", - "last_updated": "2025-09-29", + "knowledge": "2025-05", + "release_date": "2025-11-24", + "last_updated": "2025-11-24", "type": "chat" }, { - "id": "anthropic/claude-fable-5", - "name": "Claude Fable 5", - "display_name": "Claude Fable 5", + "id": "anthropic/claude-opus-4.6", + "name": "Claude Opus 4.6", + "display_name": "Claude Opus 4.6", "modalities": { "input": [ "text", @@ -92604,49 +93467,51 @@ "context": 1000000, "output": 128000 }, - "temperature": false, + "temperature": true, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": true, - "mode": "effort", + "default_enabled": false, + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, "effort": "high", "effort_options": [ "low", "medium", "high", - "xhigh", "max" ], "interleaved": true, "summaries": true, - "visibility": "omitted", + "visibility": "summary", "continuation": [ "thinking_blocks" ], "notes": [ - "Adaptive thinking is always on for Claude Fable 5 and Claude Mythos 5; thinking.type = \"disabled\" is rejected.", - "Manual budget_tokens requests return 400 on Claude Fable 5 and Claude Mythos 5.", - "thinking.display defaults to omitted; set display to summarized to receive readable thinking summaries." + "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", + "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." ] } }, "attachment": true, "open_weights": false, - "knowledge": "2026-01-31", - "release_date": "2026-06-07", - "last_updated": "2026-06-09", + "knowledge": "2025-05-31", + "release_date": "2026-02-04", + "last_updated": "2026-03-13", "type": "chat" }, { - "id": "anthropic/claude-haiku-4-5", - "name": "Claude Haiku 4.5 (latest)", - "display_name": "Claude Haiku 4.5 (latest)", + "id": "anthropic/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (latest)", + "display_name": "Claude Sonnet 4.5 (latest)", "modalities": { "input": [ "text", @@ -92658,7 +93523,7 @@ ] }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 }, "temperature": true, @@ -92690,15 +93555,15 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2025-02-28", - "release_date": "2025-10-15", - "last_updated": "2025-10-15", + "knowledge": "2025-07-31", + "release_date": "2025-09-29", + "last_updated": "2025-09-29", "type": "chat" }, { - "id": "anthropic/claude-opus-4-6", - "name": "Claude Opus 4.6", - "display_name": "Claude Opus 4.6", + "id": "anthropic/claude-fable-5", + "name": "Claude Fable 5", + "display_name": "Claude Fable 5", "modalities": { "input": [ "text", @@ -92713,45 +93578,43 @@ "context": 1000000, "output": 128000 }, - "temperature": true, + "temperature": false, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": false, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, + "default_enabled": true, + "mode": "effort", "effort": "high", "effort_options": [ "low", "medium", "high", + "xhigh", "max" ], "interleaved": true, "summaries": true, - "visibility": "summary", + "visibility": "omitted", "continuation": [ "thinking_blocks" ], "notes": [ - "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", - "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." + "Adaptive thinking is always on for Claude Fable 5 and Claude Mythos 5; thinking.type = \"disabled\" is rejected.", + "Manual budget_tokens requests return 400 on Claude Fable 5 and Claude Mythos 5.", + "thinking.display defaults to omitted; set display to summarized to receive readable thinking summaries." ] } }, "attachment": true, "open_weights": false, - "knowledge": "2025-05-31", - "release_date": "2026-02-04", - "last_updated": "2026-03-13", + "knowledge": "2026-01-31", + "release_date": "2026-06-07", + "last_updated": "2026-06-09", "type": "chat" }, { @@ -105046,6 +105909,36 @@ "last_updated": "2026-06-15", "type": "chat" }, + { + "id": "sakana-namazu", + "name": "Sakana Namazu", + "display_name": "Sakana Namazu", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-08-03", + "last_updated": "2026-08-03", + "type": "chat" + }, { "id": "fugu-ultra", "name": "Fugu Ultra", @@ -107496,46 +108389,6 @@ "last_updated": "2026-07-16", "type": "chat" }, - { - "id": "umans-glm-5.1", - "name": "GLM 5.1", - "display_name": "GLM 5.1", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 131072 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", - "type": "chat" - }, { "id": "umans-coder", "name": "Umans Coder", @@ -107865,6 +108718,60 @@ "last_updated": "2025-09-01", "type": "chat" }, + { + "id": "gpt-5.6-sol", + "name": "gpt-5.6-sol", + "display_name": "gpt-5.6-sol", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, { "id": "nvidia--llama-3.2-nv-embedqa-1b", "name": "nvidia--llama-3.2-nv-embedqa-1b", @@ -107892,6 +108799,35 @@ "last_updated": "2024-09-25", "type": "chat" }, + { + "id": "mistralai--mistral-medium", + "name": "Mistral Medium 3.5", + "display_name": "Mistral Medium 3.5", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", + "type": "chat" + }, { "id": "sonar", "name": "sonar", @@ -108163,6 +109099,60 @@ "last_updated": "2025-08-07", "type": "chat" }, + { + "id": "gpt-5.6-luna", + "name": "gpt-5.6-luna", + "display_name": "gpt-5.6-luna", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, { "id": "mistralai--mistral-medium-instruct", "name": "mistralai--mistral-medium-instruct", @@ -108644,6 +109634,38 @@ "last_updated": "2025-06-05", "type": "chat" }, + { + "id": "gemini-embedding-2", + "name": "Gemini Embedding 2", + "display_name": "Gemini Embedding 2", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 3072 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-11", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "type": "embedding" + }, { "id": "sonar-pro", "name": "sonar-pro", @@ -108703,6 +109725,60 @@ "last_updated": "2025-04-14", "type": "chat" }, + { + "id": "gpt-5.6-terra", + "name": "gpt-5.6-terra", + "display_name": "gpt-5.6-terra", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, { "id": "gpt-5.4", "name": "gpt-5.4", @@ -109452,6 +110528,300 @@ } ] }, + "arcee": { + "id": "arcee", + "name": "Arcee", + "display_name": "Arcee", + "api": "https://api.arcee.ai/api/v1", + "doc": "https://docs.arcee.ai", + "models": [ + { + "id": "trinity-large-thinking", + "name": "Trinity Large Thinking", + "display_name": "Trinity Large Thinking", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-04-01", + "last_updated": "2026-05-28", + "type": "chat" + }, + { + "id": "deepseek/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "display_name": "DeepSeek V4 Pro 0813", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, + { + "id": "deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "display_name": "DeepSeek V4 Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 512000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "The DeepSeek API maps low to high and xhigh to max for V4 Pro; effort_options lists distinct model-effective levels." + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "type": "chat" + }, + { + "id": "deepseek/deepseek-v4-flash-latest", + "name": "DeepSeek V4 Flash Latest", + "display_name": "DeepSeek V4 Flash Latest", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", + "type": "chat" + }, + { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "type": "chat" + }, + { + "id": "zai-org/glm-5.2", + "name": "GLM-5.2", + "display_name": "GLM-5.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "type": "chat" + }, + { + "id": "thinkingmachines/inkling-small", + "name": "Inkling Small", + "display_name": "Inkling Small", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-07-30", + "last_updated": "2026-07-30", + "type": "chat" + } + ] + }, "qihang-ai": { "id": "qihang-ai", "name": "QiHang", @@ -110263,6 +111633,173 @@ } ] }, + "scx-ai": { + "id": "scx-ai", + "name": "SCX.ai", + "display_name": "SCX.ai", + "api": "https://api.scx.ai/v1", + "doc": "https://platform.scx.ai/docs", + "models": [ + { + "id": "MiniMax-M2.7", + "name": "MiniMax-M2.7", + "display_name": "MiniMax-M2.7", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 196608, + "output": 196608 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "type": "chat" + }, + { + "id": "Qwen3.8-Max", + "name": "Qwen3.8 Max", + "display_name": "Qwen3.8 Max", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-08-03", + "last_updated": "2026-08-03", + "type": "chat" + }, + { + "id": "gpt-oss-120b", + "name": "GPT OSS 120B", + "display_name": "GPT OSS 120B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "type": "chat" + }, + { + "id": "GLM-5.2", + "name": "GLM-5.2", + "display_name": "GLM-5.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "type": "chat" + } + ] + }, "freemodel": { "id": "freemodel", "name": "FreeModel", @@ -112333,6 +113870,39 @@ "last_updated": "2026-03-16", "type": "chat" }, + { + "id": "z-ai/glm-5.3", + "name": "GLM-5.3", + "display_name": "GLM-5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "z-ai/glm-5.2", "name": "GLM-5.2", @@ -113006,6 +114576,58 @@ "last_updated": "2025-06-17", "type": "chat" }, + { + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "display_name": "Gemini 3.7 Flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "level", + "level": "high", + "level_options": [ + "minimal", + "low", + "medium", + "high" + ], + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-03", + "release_date": "2026-08-13", + "last_updated": "2026-08-13", + "type": "chat" + }, { "id": "x-ai/grok-4.20", "name": "Grok 4.20 (Reasoning)", @@ -113036,6 +114658,75 @@ "last_updated": "2026-03-09", "type": "chat" }, + { + "id": "x-ai/grok-4.6", + "name": "Grok 4.6", + "display_name": "Grok 4.6", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 500000, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-01", + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, + { + "id": "x-ai/grok-4.5", + "name": "Grok 4.5", + "display_name": "Grok 4.5", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 500000, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "type": "chat" + }, { "id": "x-ai/grok-4.1-fast", "name": "Grok 4.1 Fast", @@ -122884,6 +124575,63 @@ "display_name": "Azure Cognitive Services", "doc": "https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models", "models": [ + { + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "display_name": "Claude Opus 4.7", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", + "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", + "task_budget is separate from thinking control and should not be treated as a thinking budget." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "type": "chat" + }, { "id": "claude-mythos-5", "name": "Claude Mythos 5", @@ -130484,8 +132232,8 @@ }, { "id": "gpt-5.6-sol", - "name": "GPT-5.6 Sol", - "display_name": "GPT-5.6 Sol", + "name": "GPT-5.6 Sol (50% Off)", + "display_name": "GPT-5.6 Sol (50% Off)", "modalities": { "input": [ "text", @@ -133578,6 +135326,36 @@ "api": "https://router.requesty.ai/v1", "doc": "https://requesty.ai/solution/llm-routing/models", "models": [ + { + "id": "seed-2.0-code", + "name": "Seed 2.0 Code", + "display_name": "Seed 2.0 Code", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-02-14", + "last_updated": "2026-02-14", + "type": "chat" + }, { "id": "claude-opus-4-7", "name": "Claude Opus 4.7", @@ -133635,6 +135413,258 @@ "last_updated": "2026-04-16", "type": "chat" }, + { + "id": "minimax-m2.7-highspeed", + "name": "MiniMax-M2.7-highspeed", + "display_name": "MiniMax-M2.7-highspeed", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "type": "chat" + }, + { + "id": "nemotron-3.5-content-safety", + "name": "Nemotron 3.5 Content Safety", + "display_name": "Nemotron 3.5 Content Safety", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 8192 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-04", + "type": "chat" + }, + { + "id": "kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "type": "chat" + }, + { + "id": "gemma-4-26b-a4b-it", + "name": "Gemma 4 26B A4B IT", + "display_name": "Gemma 4 26B A4B IT", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "type": "chat" + }, + { + "id": "gemini-3-pro-image", + "name": "Nano Banana Pro", + "display_name": "Nano Banana Pro", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 1048576, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "level", + "level": "high", + "level_options": [ + "low", + "high" + ], + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "type": "imageGeneration" + }, + { + "id": "qwen3.7-max", + "name": "Qwen3.7 Max", + "display_name": "Qwen3.7 Max", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "type": "chat" + }, + { + "id": "seed-1.8", + "name": "seed-1.8", + "display_name": "seed-1.8", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-05-27", + "last_updated": "2026-05-27", + "type": "chat" + }, { "id": "kimi-k3", "name": "Kimi K3", @@ -133670,6 +135700,148 @@ "last_updated": "2026-07-16", "type": "chat" }, + { + "id": "gpt-5.5-pro", + "name": "GPT-5.5 Pro", + "display_name": "GPT-5.5 Pro", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "type": "chat" + }, + { + "id": "gpt-5.3-chat", + "name": "gpt-5.3-chat", + "display_name": "gpt-5.3-chat", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 16384 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "type": "chat" + }, + { + "id": "deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "display_name": "DeepSeek V4 Flash", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "The DeepSeek API maps xhigh to high for V4 Flash; effort_options lists distinct model-effective levels." + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "type": "chat" + }, + { + "id": "ring-2.6-1t", + "name": "ring-2.6-1t", + "display_name": "ring-2.6-1t", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-05-08", + "last_updated": "2026-05-08", + "type": "chat" + }, { "id": "claude-opus-4-8", "name": "Claude Opus 4.8", @@ -133685,10 +135857,1361 @@ ] }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1000000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", + "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", + "task_budget is separate from thinking control and should not be treated as a thinking budget." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "type": "chat" + }, + { + "id": "gemini-3.5-flash-lite", + "name": "Gemini 3.5 Flash Lite", + "display_name": "Gemini 3.5 Flash Lite", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 65535 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-03", + "release_date": "2026-07-21", + "last_updated": "2026-07-21", + "type": "chat" + }, + { + "id": "mimo-v2.5", + "name": "MiMo-V2.5", + "display_name": "MiMo-V2.5", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "type": "chat" + }, + { + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "display_name": "GPT-5.6 Sol", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, + { + "id": "grok-4.6", + "name": "Grok 4.6", + "display_name": "Grok 4.6", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 500000, + "output": 500000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-01", + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, + { + "id": "gpt-5.4-pro", + "name": "GPT-5.4 Pro", + "display_name": "GPT-5.4 Pro", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "type": "chat" + }, + { + "id": "qwen3.5-35b-a3b", + "name": "Qwen3.5 35B-A3B", + "display_name": "Qwen3.5 35B-A3B", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "type": "chat" + }, + { + "id": "gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro Preview", + "display_name": "Gemini 3.1 Pro Preview", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 65535 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "level", + "level": "high", + "level_options": [ + "low", + "high" + ], + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-01", + "release_date": "2026-02-19", + "last_updated": "2026-02-19", + "type": "chat" + }, + { + "id": "nemotron-3-ultra-nvfp4", + "name": "nemotron-3-ultra-nvfp4", + "display_name": "nemotron-3-ultra-nvfp4", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "type": "chat" + }, + { + "id": "grok-4.5", + "name": "Grok 4.5", + "display_name": "Grok 4.5", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 500000, + "output": 500000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-08", + "last_updated": "2026-07-08", + "type": "chat" + }, + { + "id": "claude-opus-4-1", + "name": "Claude Opus 4.1 (latest)", + "display_name": "Claude Opus 4.1 (latest)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 32000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "budget", + "budget": { + "min": 1024, + "unit": "tokens" + }, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Claude 4 manual thinking uses thinking.type = \"enabled\" with budget_tokens.", + "Interleaved thinking requires the interleaved-thinking-2025-05-14 beta header for this model family." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-03-31", + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "type": "chat" + }, + { + "id": "deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "display_name": "DeepSeek V4 Pro 0813", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, + { + "id": "deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "display_name": "DeepSeek V4 Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "The DeepSeek API maps low to high and xhigh to max for V4 Pro; effort_options lists distinct model-effective levels." + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "type": "chat" + }, + { + "id": "nemotron-3-super-120b-a12b", + "name": "Nemotron 3 Super 120B A12B", + "display_name": "Nemotron 3 Super 120B A12B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-03-11", + "last_updated": "2026-03-11", + "type": "chat" + }, + { + "id": "laguna-m.1", + "name": "Laguna M.1", + "display_name": "Laguna M.1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 32768, + "output": 32768 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-04-28", + "last_updated": "2026-06-13", + "type": "chat" + }, + { + "id": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "display_name": "Claude Sonnet 5", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "type": "chat" + }, + { + "id": "glm-5.2-fast", + "name": "glm-5.2-fast", + "display_name": "glm-5.2-fast", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-07-13", + "last_updated": "2026-07-13", + "type": "chat" + }, + { + "id": "ling-2.6-1t", + "name": "ling-2.6-1t", + "display_name": "ling-2.6-1t", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "type": "chat" + }, + { + "id": "gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "display_name": "GPT-5.6 Luna", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, + { + "id": "gemma-4-31b-it", + "name": "Gemma 4 31B IT", + "display_name": "Gemma 4 31B IT", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 8192 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "type": "chat" + }, + { + "id": "devstral-latest", + "name": "devstral-latest", + "display_name": "devstral-latest", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-05-27", + "last_updated": "2026-05-27", + "type": "chat" + }, + { + "id": "mistral-medium-3-5", + "name": "mistral-medium-3-5", + "display_name": "mistral-medium-3-5", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-04-30", + "last_updated": "2026-04-30", + "type": "chat" + }, + { + "id": "nemotron-3-nano-omni-30b-a3b-reasoning", + "name": "Nemotron 3 Nano Omni 30B A3B Reasoning", + "display_name": "Nemotron 3 Nano Omni 30B A3B Reasoning", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 20480 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-28", + "last_updated": "2026-04-28", + "type": "chat" + }, + { + "id": "nemotron-lightning-3.5-30b-a3b", + "name": "nemotron-lightning-3.5-30b-a3b", + "display_name": "nemotron-lightning-3.5-30b-a3b", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-15", + "last_updated": "2026-08-15", + "type": "chat" + }, + { + "id": "minimax-m3", + "name": "MiniMax-M3", + "display_name": "MiniMax-M3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "type": "chat" + }, + { + "id": "minimax-m2.7", + "name": "MiniMax-M2.7", + "display_name": "MiniMax-M2.7", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "type": "chat" + }, + { + "id": "inkling-256k", + "name": "inkling-256k", + "display_name": "inkling-256k", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "type": "chat" + }, + { + "id": "qwen3.5-2b", + "name": "qwen3.5-2b", + "display_name": "qwen3.5-2b", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-03-10", + "last_updated": "2026-03-10", + "type": "chat" + }, + { + "id": "gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "display_name": "GPT-5.3 Codex", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "type": "chat" + }, + { + "id": "qwen3.8-max", + "name": "Qwen3.8 Max", + "display_name": "Qwen3.8 Max", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-08-03", + "last_updated": "2026-08-03", + "type": "chat" + }, + { + "id": "muse-glimmer-30b", + "name": "Muse Glimmer 30B", + "display_name": "Muse Glimmer 30B", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 20480 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2026-01-04", + "release_date": "2026-08-10", + "last_updated": "2026-08-10", + "type": "chat" + }, + { + "id": "qwen3.7-plus", + "name": "Qwen3.7 Plus", + "display_name": "Qwen3.7 Plus", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "type": "chat" + }, + { + "id": "grok-4.2-beta", + "name": "grok-4.2-beta", + "display_name": "grok-4.2-beta", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-03-19", + "last_updated": "2026-03-19", + "type": "chat" + }, + { + "id": "fugu-ultra", + "name": "Fugu Ultra", + "display_name": "Fugu Ultra", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-06-15", + "last_updated": "2026-06-15", + "type": "chat" + }, + { + "id": "mistral-small-2603", + "name": "Mistral Small 4", + "display_name": "Mistral Small 4", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-06", + "release_date": "2026-03-16", + "last_updated": "2026-03-16", + "type": "chat" + }, + { + "id": "gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "display_name": "Gemini 3.5 Flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 65535 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "level", + "level": "high", + "level_options": [ + "minimal", + "low", + "medium", + "high" + ], + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-01", + "release_date": "2026-05-19", + "last_updated": "2026-05-19", + "type": "chat" + }, + { + "id": "claude-opus-4-5", + "name": "Claude Opus 4.5 (latest)", + "display_name": "Claude Opus 4.5 (latest)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 64000 }, - "temperature": false, + "temperature": true, "tool_call": true, "reasoning": { "supported": true, @@ -133698,89 +137221,103 @@ "reasoning": { "supported": true, "default_enabled": false, - "mode": "effort", + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, "effort": "high", "effort_options": [ "low", "medium", - "high", - "xhigh", - "max" + "high" ], "interleaved": true, "summaries": true, - "visibility": "omitted", + "visibility": "summary", "continuation": [ "thinking_blocks" ], "notes": [ - "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", - "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", - "task_budget is separate from thinking control and should not be treated as a thinking budget." + "Claude Opus 4.5 uses manual thinking.type = \"enabled\" with budget_tokens; effort can be used alongside the thinking budget.", + "Interleaved thinking requires the interleaved-thinking-2025-05-14 beta header." ] } }, "attachment": true, "open_weights": false, - "knowledge": "2026-01", - "release_date": "2026-05-28", - "last_updated": "2026-05-28", + "knowledge": "2025-05", + "release_date": "2025-11-24", + "last_updated": "2025-11-24", "type": "chat" }, { - "id": "gemini-3.5-flash-lite", - "name": "Gemini 3.5 Flash Lite", - "display_name": "Gemini 3.5 Flash Lite", + "id": "gpt-5.4-mini", + "name": "GPT-5.4 mini", + "display_name": "GPT-5.4 mini", "modalities": { "input": [ "text", - "image", - "video", - "audio", - "pdf" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 65535 + "context": 400000, + "output": 128000 }, - "temperature": true, + "temperature": false, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "attachment": true, "open_weights": false, - "knowledge": "2026-03", - "release_date": "2026-07-21", - "last_updated": "2026-07-21", + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", "type": "chat" }, { - "id": "grok-4.5", - "name": "Grok 4.5", - "display_name": "Grok 4.5", + "id": "nemotron-3-ultra-550b-a55b", + "name": "Nemotron 3 Ultra 550B A55B", + "display_name": "Nemotron 3 Ultra 550B A55B", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "limit": { - "context": 500000, - "output": 500000 + "context": 1048576, + "output": 65536 }, "temperature": true, "tool_call": true, @@ -133793,16 +137330,16 @@ "supported": true } }, - "attachment": true, - "open_weights": false, - "release_date": "2026-07-08", - "last_updated": "2026-07-08", + "attachment": false, + "open_weights": true, + "release_date": "2026-06-04", + "last_updated": "2026-06-04", "type": "chat" }, { - "id": "claude-opus-4-1", - "name": "Claude Opus 4.1 (latest)", - "display_name": "Claude Opus 4.1 (latest)", + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "display_name": "Claude Sonnet 4.6", "modalities": { "input": [ "text", @@ -133814,8 +137351,8 @@ ] }, "limit": { - "context": 200000, - "output": 32000 + "context": 1000000, + "output": 128000 }, "temperature": true, "tool_call": true, @@ -133827,11 +137364,18 @@ "reasoning": { "supported": true, "default_enabled": false, - "mode": "budget", + "mode": "mixed", "budget": { "min": 1024, "unit": "tokens" }, + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "max" + ], "interleaved": true, "summaries": true, "visibility": "summary", @@ -133839,26 +137383,28 @@ "thinking_blocks" ], "notes": [ - "Claude 4 manual thinking uses thinking.type = \"enabled\" with budget_tokens.", - "Interleaved thinking requires the interleaved-thinking-2025-05-14 beta header for this model family." + "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", + "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." ] } }, "attachment": true, "open_weights": false, - "knowledge": "2025-03-31", - "release_date": "2025-08-05", - "last_updated": "2025-08-05", + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", "type": "chat" }, { - "id": "claude-sonnet-5", - "name": "Claude Sonnet 5", - "display_name": "Claude Sonnet 5", + "id": "gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "display_name": "Gemini 3.1 Flash Lite", "modalities": { "input": [ "text", "image", + "video", + "audio", "pdf" ], "output": [ @@ -133866,10 +137412,10 @@ ] }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 65535 }, - "temperature": false, + "temperature": true, "tool_call": true, "reasoning": { "supported": true, @@ -133882,78 +137428,100 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2026-01-31", - "release_date": "2026-06-30", - "last_updated": "2026-06-30", + "knowledge": "2025-01", + "release_date": "2026-05-07", + "last_updated": "2026-05-07", "type": "chat" }, { - "id": "claude-opus-4-5", - "name": "Claude Opus 4.5 (latest)", - "display_name": "Claude Opus 4.5 (latest)", + "id": "inkling", + "name": "Inkling", + "display_name": "Inkling", "modalities": { "input": [ "text", "image", - "pdf" + "audio" ], "output": [ "text" ] }, "limit": { - "context": 200000, - "output": 64000 + "context": 65536, + "output": 32768 }, "temperature": true, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-07-15", + "last_updated": "2026-07-15", + "type": "chat" + }, + { + "id": "gpt-5.5", + "name": "GPT-5.5", + "display_name": "GPT-5.5", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": false, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, - "effort": "high", + "default_enabled": true, + "mode": "effort", + "effort": "medium", "effort_options": [ "low", "medium", - "high" + "high", + "xhigh" ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" ], - "notes": [ - "Claude Opus 4.5 uses manual thinking.type = \"enabled\" with budget_tokens; effort can be used alongside the thinking budget.", - "Interleaved thinking requires the interleaved-thinking-2025-05-14 beta header." - ] + "visibility": "hidden" } }, "attachment": true, "open_weights": false, - "knowledge": "2025-05", - "release_date": "2025-11-24", - "last_updated": "2025-11-24", + "knowledge": "2025-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", "type": "chat" }, { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "display_name": "Claude Sonnet 4.6", + "id": "glm-5.3", + "name": "GLM-5.3", + "display_name": "GLM-5.3", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" @@ -133967,41 +137535,131 @@ "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "extra_capabilities": { "reasoning": { - "supported": true, - "default_enabled": false, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, - "effort": "high", - "effort_options": [ - "low", - "medium", - "high", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", - "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." - ] + "supported": true } }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, + { + "id": "seed-2.0-pro", + "name": "Seed 2.0 Pro", + "display_name": "Seed 2.0 Pro", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, "attachment": true, "open_weights": false, - "knowledge": "2025-08-31", - "release_date": "2026-02-17", - "last_updated": "2026-03-13", + "release_date": "2026-02-14", + "last_updated": "2026-02-14", + "type": "chat" + }, + { + "id": "step-3.7-flash", + "name": "Step 3.7 Flash", + "display_name": "Step 3.7 Flash", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 256000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "knowledge": "2026-03-01", + "release_date": "2026-05-29", + "last_updated": "2026-05-29", + "type": "chat" + }, + { + "id": "nemotron-3.5-lightning-30b-a3b", + "name": "nemotron-3.5-lightning-30b-a3b", + "display_name": "nemotron-3.5-lightning-30b-a3b", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 65536 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-11", + "last_updated": "2026-08-11", + "type": "chat" + }, + { + "id": "kat-coder-pro", + "name": "kat-coder-pro", + "display_name": "kat-coder-pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-03-27", + "last_updated": "2026-03-27", "type": "chat" }, { @@ -134141,6 +137799,317 @@ "last_updated": "2026-07-21", "type": "chat" }, + { + "id": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "display_name": "GPT-5.6 Terra", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, + { + "id": "gemini-3.1-flash-image", + "name": "Nano Banana 2", + "display_name": "Nano Banana 2", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-01", + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "type": "imageGeneration" + }, + { + "id": "nvidia-nemotron-3-super-120b-a12b", + "name": "nvidia-nemotron-3-super-120b-a12b", + "display_name": "nvidia-nemotron-3-super-120b-a12b", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-03-11", + "last_updated": "2026-03-11", + "type": "chat" + }, + { + "id": "gpt-5.4", + "name": "GPT-5.4", + "display_name": "GPT-5.4", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "type": "chat" + }, + { + "id": "nemotron-3-nano-omni", + "name": "nemotron-3-nano-omni", + "display_name": "nemotron-3-nano-omni", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 300000, + "output": 300000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-05-20", + "last_updated": "2026-05-20", + "type": "chat" + }, + { + "id": "qwen3.6-plus", + "name": "Qwen3.6 Plus", + "display_name": "Qwen3.6 Plus", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-04", + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "type": "chat" + }, + { + "id": "qwen3.8-2.4T-A95B", + "name": "Qwen3.8 2.4T A95B", + "display_name": "Qwen3.8 2.4T A95B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, + { + "id": "ling-3.0-tiny", + "name": "ling-3.0-tiny", + "display_name": "ling-3.0-tiny", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-05", + "last_updated": "2026-08-05", + "type": "chat" + }, { "id": "claude-fable-5", "name": "Claude Fable 5", @@ -134198,6 +138167,164 @@ "last_updated": "2026-06-09", "type": "chat" }, + { + "id": "qwen3.5-27b", + "name": "Qwen3.5 27B", + "display_name": "Qwen3.5 27B", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "type": "chat" + }, + { + "id": "ling-2.6-flash", + "name": "ling-2.6-flash", + "display_name": "ling-2.6-flash", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "type": "chat" + }, + { + "id": "seed-2.0-mini", + "name": "Seed 2.0 Mini", + "display_name": "Seed 2.0 Mini", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-02-14", + "last_updated": "2026-02-14", + "type": "chat" + }, + { + "id": "nvidia-nemotron-3-ultra", + "name": "nvidia-nemotron-3-ultra", + "display_name": "nvidia-nemotron-3-ultra", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 131072 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-06-23", + "last_updated": "2026-06-23", + "type": "chat" + }, + { + "id": "glm-5.1", + "name": "GLM-5.1", + "display_name": "GLM-5.1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "type": "chat" + }, { "id": "claude-haiku-4-5", "name": "Claude Haiku 4.5 (latest)", @@ -134250,6 +138377,94 @@ "last_updated": "2025-10-15", "type": "chat" }, + { + "id": "mimo-v2.5-pro", + "name": "MiMo-V2.5-Pro", + "display_name": "MiMo-V2.5-Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "type": "chat" + }, + { + "id": "thinkingcap-qwen3.6-27b", + "name": "thinkingcap-qwen3.6-27b", + "display_name": "thinkingcap-qwen3.6-27b", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-07-13", + "last_updated": "2026-07-13", + "type": "chat" + }, + { + "id": "hy3", + "name": "Hy3", + "display_name": "Hy3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-07-06", + "last_updated": "2026-07-06", + "type": "chat" + }, { "id": "claude-opus-4-6", "name": "Claude Opus 4.6", @@ -134345,6 +138560,60 @@ "last_updated": "2026-07-24", "type": "chat" }, + { + "id": "leanstral-1-5", + "name": "leanstral-1-5", + "display_name": "leanstral-1-5", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-05-27", + "last_updated": "2026-05-27", + "type": "chat" + }, + { + "id": "mistral-medium-latest", + "name": "Mistral Medium (latest)", + "display_name": "Mistral Medium (latest)", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", + "type": "chat" + }, { "id": "deepseek-v4-flash-0731", "name": "DeepSeek V4 Flash 0731", @@ -134379,6 +138648,68 @@ "last_updated": "2026-07-31", "type": "chat" }, + { + "id": "grok-build-0.1", + "name": "Grok Build 0.1", + "display_name": "Grok Build 0.1", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "type": "chat" + }, + { + "id": "laguna-xs.2", + "name": "Laguna XS.2", + "display_name": "Laguna XS.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 32768, + "output": 32768 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-04-28", + "last_updated": "2026-06-13", + "type": "chat" + }, { "id": "gemini-3.7-flash", "name": "Gemini 3.7 Flash", @@ -134430,6 +138761,129 @@ "release_date": "2026-08-13", "last_updated": "2026-08-13", "type": "chat" + }, + { + "id": "gpt-5.4-nano", + "name": "GPT-5.4 nano", + "display_name": "GPT-5.4 nano", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "type": "chat" + }, + { + "id": "grok-4.3", + "name": "Grok 4.3", + "display_name": "Grok 4.3", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 1000000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "type": "chat" + }, + { + "id": "kimi-k2.6", + "name": "Kimi K2.6", + "display_name": "Kimi K2.6", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "type": "chat" } ] }, @@ -137064,7 +141518,7 @@ ] }, "limit": { - "context": 202800, + "context": 202750, "output": 3276 }, "temperature": true, @@ -137329,6 +141783,63 @@ "last_updated": "2025-12-01", "type": "chat" }, + { + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "display_name": "Claude Opus 4.7", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", + "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", + "task_budget is separate from thinking control and should not be treated as a thinking budget." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "type": "chat" + }, { "id": "gpt-4o", "name": "GPT-4o", @@ -142555,7 +147066,13 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "attachment": true, @@ -142590,7 +147107,13 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "attachment": true, @@ -142664,7 +147187,13 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "attachment": false, @@ -142676,8 +147205,8 @@ }, { "id": "deepseek-ai/DeepSeek-V4-Pro", - "name": "Deepseek V4 Pro", - "display_name": "Deepseek V4 Pro", + "name": "DeepSeek V4 Pro", + "display_name": "DeepSeek V4 Pro", "modalities": { "input": [ "text" @@ -142687,7 +147216,7 @@ ] }, "limit": { - "context": 262144, + "context": 1048576, "output": 262144 }, "temperature": true, @@ -142726,8 +147255,8 @@ }, { "id": "deepseek-ai/DeepSeek-V4-Flash-0731", - "name": "Deepseek V4 Flash 0731", - "display_name": "Deepseek V4 Flash 0731", + "name": "DeepSeek V4 Flash 0731", + "display_name": "DeepSeek V4 Flash 0731", "modalities": { "input": [ "text" @@ -142738,7 +147267,7 @@ }, "limit": { "context": 1048576, - "output": 1048576 + "output": 384000 }, "temperature": true, "tool_call": true, @@ -142794,8 +147323,8 @@ }, { "id": "deepseek-ai/DeepSeek-V4-Pro-0813", - "name": "Deepseek V4 Pro 0813", - "display_name": "Deepseek V4 Pro 0813", + "name": "DeepSeek V4 Pro 0813", + "display_name": "DeepSeek V4 Pro 0813", "modalities": { "input": [ "text" @@ -142816,7 +147345,13 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "attachment": false, @@ -142870,7 +147405,8 @@ "display_name": "GLM 5.2 Fast", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -142897,7 +147433,7 @@ ] } }, - "attachment": false, + "attachment": true, "open_weights": true, "release_date": "2026-06-13", "last_updated": "2026-06-13", @@ -142989,7 +147525,8 @@ "display_name": "GLM 5.2", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" @@ -143016,7 +147553,7 @@ ] } }, - "attachment": false, + "attachment": true, "open_weights": true, "release_date": "2026-06-13", "last_updated": "2026-06-13", @@ -153995,6 +158532,37 @@ "last_updated": "2026-06-13", "type": "chat" }, + { + "id": "glm-4-6v-flash", + "name": "GLM 4.6V Flash", + "display_name": "GLM 4.6V Flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2025-12-08", + "last_updated": "2025-12-08", + "type": "chat" + }, { "id": "qwen3-5-27b", "name": "Qwen3.5 27B", @@ -154272,6 +158840,34 @@ "last_updated": "2026-08-10", "type": "chat" }, + { + "id": "glm-5-3", + "name": "GLM 5.3", + "display_name": "GLM 5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "qwen3-6-27b", "name": "Qwen3.6 27B", @@ -157692,34 +162288,6 @@ "display_name": "Cerebras", "doc": "https://inference-docs.cerebras.ai/models/overview", "models": [ - { - "id": "zai-glm-4.7", - "name": "Z.AI GLM-4.7", - "display_name": "Z.AI GLM-4.7", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 40960 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": true, - "release_date": "2026-01-07", - "last_updated": "2026-06-10", - "type": "chat" - }, { "id": "gpt-oss-120b", "name": "GPT OSS 120B", @@ -158108,6 +162676,58 @@ "last_updated": "2026-07-06", "type": "chat" }, + { + "id": "fish-audio/s1-free", + "name": "S1 (Free)", + "display_name": "S1 (Free)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2025-10-20", + "last_updated": "2025-10-20", + "type": "chat" + }, + { + "id": "fish-audio/s2-pro-free", + "name": "S2 Pro (Free)", + "display_name": "S2 Pro (Free)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-03-09", + "last_updated": "2026-03-09", + "type": "chat" + }, { "id": "fish-audio/s1", "name": "S1", @@ -158160,6 +162780,32 @@ "last_updated": "2026-03-01", "type": "chat" }, + { + "id": "fish-audio/transcribe-1-free", + "name": "Transcribe-1 (Free)", + "display_name": "Transcribe-1 (Free)", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-03-01", + "last_updated": "2026-03-01", + "type": "chat" + }, { "id": "fish-audio/s2-pro", "name": "S2 Pro", @@ -158186,6 +162832,32 @@ "last_updated": "2026-03-09", "type": "chat" }, + { + "id": "fish-audio/s2.1-pro-free", + "name": "S2.1 Pro (Free)", + "display_name": "S2.1 Pro (Free)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-07-28", + "last_updated": "2026-07-28", + "type": "chat" + }, { "id": "fish-audio/s2.1-pro", "name": "S2.1 Pro", @@ -158456,8 +163128,7 @@ } }, "attachment": false, - "open_weights": false, - "knowledge": "2024-10", + "open_weights": true, "release_date": "2025-12-15", "last_updated": "2025-12-15", "type": "chat" @@ -158491,8 +163162,7 @@ } }, "attachment": true, - "open_weights": false, - "knowledge": "2024-10", + "open_weights": true, "release_date": "2025-10-28", "last_updated": "2025-10-28", "type": "chat" @@ -158525,7 +163195,7 @@ } }, "attachment": false, - "open_weights": false, + "open_weights": true, "release_date": "2026-03-11", "last_updated": "2026-03-11", "type": "chat" @@ -158558,7 +163228,7 @@ } }, "attachment": false, - "open_weights": false, + "open_weights": true, "release_date": "2026-08-11", "last_updated": "2026-08-11", "type": "chat" @@ -158591,7 +163261,7 @@ } }, "attachment": false, - "open_weights": false, + "open_weights": true, "release_date": "2026-06-04", "last_updated": "2026-06-04", "type": "chat" @@ -158624,8 +163294,7 @@ } }, "attachment": false, - "open_weights": false, - "knowledge": "2024-10", + "open_weights": true, "release_date": "2025-08-18", "last_updated": "2025-08-18", "type": "chat" @@ -158706,7 +163375,7 @@ "attachment": false, "open_weights": true, "knowledge": "2025-05", - "release_date": "2026-04-23", + "release_date": "2026-04-24", "last_updated": "2026-04-24", "type": "chat" }, @@ -158766,8 +163435,7 @@ "default": true }, "attachment": false, - "open_weights": false, - "knowledge": "2024-07", + "open_weights": true, "release_date": "2025-08-21", "last_updated": "2025-08-21", "type": "chat" @@ -158818,7 +163486,7 @@ "attachment": false, "open_weights": true, "knowledge": "2025-05", - "release_date": "2026-04-23", + "release_date": "2026-04-24", "last_updated": "2026-04-24", "type": "chat" }, @@ -158844,8 +163512,7 @@ "supported": false }, "attachment": false, - "open_weights": false, - "knowledge": "2024-07", + "open_weights": true, "release_date": "2024-12-26", "last_updated": "2024-12-26", "type": "chat" @@ -158877,7 +163544,7 @@ } }, "attachment": false, - "open_weights": false, + "open_weights": true, "knowledge": "2024-07", "release_date": "2025-12-01", "last_updated": "2025-12-01", @@ -158913,7 +163580,7 @@ "attachment": false, "open_weights": true, "knowledge": "2025-05", - "release_date": "2026-04-23", + "release_date": "2026-07-31", "last_updated": "2026-07-31", "type": "chat" }, @@ -159105,7 +163772,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-04-02", "last_updated": "2026-04-02", "type": "chat" @@ -159154,7 +163821,7 @@ "attachment": false, "open_weights": false, "knowledge": "2025-01", - "release_date": "2025-09-01", + "release_date": "2026-05-28", "last_updated": "2026-05-28", "type": "imageGeneration" }, @@ -159438,7 +164105,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-04-02", "last_updated": "2026-04-02", "type": "chat" @@ -159719,7 +164386,7 @@ "attachment": false, "open_weights": false, "knowledge": "2025-11", - "release_date": "2026-03-10", + "release_date": "2026-04-22", "last_updated": "2026-04-22", "type": "embedding" }, @@ -160290,7 +164957,7 @@ }, "attachment": false, "open_weights": false, - "release_date": "2026-07-09", + "release_date": "2026-04-08", "last_updated": "2026-07-09", "type": "chat" }, @@ -160822,7 +165489,7 @@ }, "attachment": false, "open_weights": true, - "release_date": "2026-07-20", + "release_date": "2026-07-21", "last_updated": "2026-07-21", "type": "chat" }, @@ -161180,7 +165847,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "knowledge": "2025-01", "release_date": "2026-06-12", "last_updated": "2026-06-12", @@ -161216,7 +165883,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-07-16", "last_updated": "2026-07-16", "type": "chat" @@ -161246,8 +165913,8 @@ "default": true }, "attachment": true, - "open_weights": false, - "release_date": "2026-07-27", + "open_weights": true, + "release_date": "2026-07-16", "last_updated": "2026-07-16", "type": "chat" }, @@ -161315,7 +165982,7 @@ "attachment": true, "open_weights": true, "knowledge": "2025-01", - "release_date": "2026-01-26", + "release_date": "2026-01", "last_updated": "2026-01", "type": "chat" }, @@ -161349,9 +166016,9 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "knowledge": "2025-01", - "release_date": "2026-06-15", + "release_date": "2026-06-12", "last_updated": "2026-06-12", "type": "chat" }, @@ -161389,7 +166056,7 @@ } }, "attachment": false, - "open_weights": false, + "open_weights": true, "knowledge": "2024-08", "release_date": "2025-11-06", "last_updated": "2025-11-06", @@ -161426,7 +166093,7 @@ "attachment": true, "open_weights": true, "knowledge": "2025-01", - "release_date": "2026-04-20", + "release_date": "2026-04-21", "last_updated": "2026-04-21", "type": "chat" }, @@ -161607,7 +166274,7 @@ "attachment": true, "open_weights": false, "knowledge": "2026-01-31", - "release_date": "2026-06-29", + "release_date": "2026-06-30", "last_updated": "2026-06-30", "type": "chat" }, @@ -162051,7 +166718,7 @@ "attachment": true, "open_weights": false, "knowledge": "2026-01-31", - "release_date": "2026-07-01", + "release_date": "2026-06-09", "last_updated": "2026-06-09", "type": "chat" }, @@ -162409,8 +167076,8 @@ } }, "attachment": false, - "open_weights": false, - "knowledge": "2024-10", + "open_weights": true, + "knowledge": "2025-04", "release_date": "2025-12-22", "last_updated": "2025-12-22", "type": "chat" @@ -162468,42 +167135,11 @@ "default": true }, "attachment": false, - "open_weights": false, - "release_date": "2026-06-23", + "open_weights": true, + "release_date": "2026-06-13", "last_updated": "2026-06-13", "type": "chat" }, - { - "id": "zai/glm-4.6v-flash", - "name": "GLM-4.6V-Flash", - "display_name": "GLM-4.6V-Flash", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 128000, - "output": 24000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": true, - "open_weights": false, - "knowledge": "2024-10", - "release_date": "2025-09-30", - "last_updated": "2025-09-30", - "type": "chat" - }, { "id": "zai/glm-4.7-flashx", "name": "GLM 4.7 FlashX", @@ -162607,14 +167243,14 @@ }, "attachment": false, "open_weights": false, - "release_date": "2026-03-15", + "release_date": "2026-03-16", "last_updated": "2026-03-16", "type": "chat" }, { - "id": "zai/glm-5.2", - "name": "GLM 5.2", - "display_name": "GLM 5.2", + "id": "zai/glm-5.3", + "name": "GLM 5.3", + "display_name": "GLM 5.3", "modalities": { "input": [ "text" @@ -162625,7 +167261,7 @@ }, "limit": { "context": 1000000, - "output": 128000 + "output": 12800 }, "temperature": true, "tool_call": true, @@ -162639,28 +167275,26 @@ } }, "attachment": false, - "open_weights": true, - "release_date": "2026-06-16", - "last_updated": "2026-06-13", + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", "type": "chat" }, { - "id": "zai/glm-4.6v", - "name": "GLM-4.6V", - "display_name": "GLM-4.6V", + "id": "zai/glm-5.2", + "name": "GLM 5.2", + "display_name": "GLM 5.2", "modalities": { "input": [ - "text", - "image", - "pdf" + "text" ], "output": [ "text" ] }, "limit": { - "context": 128000, - "output": 24000 + "context": 1000000, + "output": 128000 }, "temperature": true, "tool_call": true, @@ -162668,11 +167302,15 @@ "supported": true, "default": true }, - "attachment": true, - "open_weights": false, - "knowledge": "2024-10", - "release_date": "2025-09-30", - "last_updated": "2025-12-08", + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "type": "chat" }, { @@ -162732,7 +167370,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-04-07", "last_updated": "2026-04-07", "type": "chat" @@ -162765,7 +167403,7 @@ } }, "attachment": false, - "open_weights": false, + "open_weights": true, "knowledge": "2025-04", "release_date": "2026-01-19", "last_updated": "2026-01-19", @@ -163137,6 +167775,36 @@ "last_updated": "2026-04-14", "type": "chat" }, + { + "id": "openai/gpt-4o-mini-fast", + "name": "GPT-4o mini (Fast)", + "display_name": "GPT-4o mini (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 16384 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2023-09", + "release_date": "2024-07-18", + "last_updated": "2024-07-18", + "type": "chat" + }, { "id": "openai/gpt-image-1.5", "name": "GPT Image 1.5", @@ -163160,7 +167828,7 @@ }, "attachment": false, "open_weights": false, - "release_date": "2025-12-16", + "release_date": "2025-11-25", "last_updated": "2025-11-25", "type": "imageGeneration" }, @@ -163217,6 +167885,56 @@ "last_updated": "2025-08-07", "type": "chat" }, + { + "id": "openai/gpt-5.1-thinking-fast", + "name": "GPT 5.1 Thinking (Fast)", + "display_name": "GPT 5.1 Thinking (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2025-11-12", + "last_updated": "2025-11-12", + "type": "chat" + }, { "id": "openai/gpt-realtime-1.5", "name": "GPT-Realtime-1.5", @@ -163274,6 +167992,37 @@ "last_updated": "2023-11-06", "type": "chat" }, + { + "id": "openai/gpt-5.6-luna-fast", + "name": "GPT 5.6 Luna (Fast)", + "display_name": "GPT 5.6 Luna (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, { "id": "openai/gpt-4o-mini-transcribe", "name": "GPT-4o mini Transcribe", @@ -163333,10 +168082,191 @@ "attachment": true, "open_weights": false, "knowledge": "2025-12-01", - "release_date": "2026-04-24", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "type": "chat" + }, + { + "id": "openai/gpt-5.4-fast", + "name": "GPT 5.4 (Fast)", + "display_name": "GPT 5.4 (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "type": "chat" + }, + { + "id": "openai/o4-mini-fast", + "name": "o4-mini (Fast)", + "display_name": "o4-mini (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 100000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2024-05", + "release_date": "2025-04-16", + "last_updated": "2025-04-16", + "type": "chat" + }, + { + "id": "openai/gpt-5.5-fast", + "name": "GPT 5.5 (Fast)", + "display_name": "GPT 5.5 (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-12-01", + "release_date": "2026-04-23", "last_updated": "2026-04-23", "type": "chat" }, + { + "id": "openai/gpt-5.3-codex-fast", + "name": "GPT 5.3 Codex (Fast)", + "display_name": "GPT 5.3 Codex (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "type": "chat" + }, { "id": "openai/gpt-image-1-mini", "name": "GPT Image 1 Mini", @@ -163503,8 +168433,8 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2024-10", - "release_date": "2025-06-26", + "knowledge": "2024-05", + "release_date": "2024-06-26", "last_updated": "2024-06-26", "type": "chat" }, @@ -163633,8 +168563,8 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2024-10", - "release_date": "2025-11-12", + "knowledge": "2024-09-30", + "release_date": "2025-11-13", "last_updated": "2025-11-13", "type": "chat" }, @@ -163771,6 +168701,36 @@ "last_updated": "2022-12-15", "type": "embedding" }, + { + "id": "openai/gpt-4o-fast", + "name": "GPT-4o (Fast)", + "display_name": "GPT-4o (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 16384 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2023-09", + "release_date": "2024-05-13", + "last_updated": "2024-08-06", + "type": "chat" + }, { "id": "openai/gpt-5.3-codex", "name": "GPT 5.3 Codex", @@ -163910,6 +168870,37 @@ "last_updated": "2025-12-11", "type": "chat" }, + { + "id": "openai/gpt-5-fast", + "name": "GPT-5 (Fast)", + "display_name": "GPT-5 (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2024-09-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "type": "chat" + }, { "id": "openai/gpt-image-1", "name": "GPT Image 1", @@ -163933,7 +168924,7 @@ }, "attachment": false, "open_weights": false, - "release_date": "2025-03-25", + "release_date": "2025-04-24", "last_updated": "2025-04-24", "type": "imageGeneration" }, @@ -163964,7 +168955,7 @@ "attachment": false, "open_weights": false, "knowledge": "2024-09-30", - "release_date": "2026-07-09", + "release_date": "2026-07-06", "last_updated": "2026-07-06", "type": "chat" }, @@ -164089,7 +169080,7 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2024-10", + "knowledge": "2024-09-30", "release_date": "2025-10-06", "last_updated": "2025-10-06", "type": "chat" @@ -164142,7 +169133,7 @@ "attachment": true, "open_weights": false, "knowledge": "2025-12-01", - "release_date": "2026-04-24", + "release_date": "2026-04-23", "last_updated": "2026-04-23", "type": "chat" }, @@ -164173,6 +169164,36 @@ "last_updated": "2023-11-06", "type": "chat" }, + { + "id": "openai/gpt-4.1-mini-fast", + "name": "GPT-4.1 mini (Fast)", + "display_name": "GPT-4.1 mini (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1047576, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2024-04", + "release_date": "2025-04-14", + "last_updated": "2025-04-14", + "type": "chat" + }, { "id": "openai/gpt-5.2-codex", "name": "GPT-5.2-Codex", @@ -164220,8 +169241,61 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2024-10", - "release_date": "2025-12-18", + "knowledge": "2025-08-31", + "release_date": "2025-12-11", + "last_updated": "2025-12-11", + "type": "chat" + }, + { + "id": "openai/gpt-5.2-fast", + "name": "GPT 5.2 (Fast)", + "display_name": "GPT 5.2 (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2025-12-11", "last_updated": "2025-12-11", "type": "chat" }, @@ -164252,6 +169326,37 @@ "last_updated": "2023-11-06", "type": "chat" }, + { + "id": "openai/gpt-5-mini-fast", + "name": "GPT-5 mini (Fast)", + "display_name": "GPT-5 mini (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2024-05-30", + "release_date": "2025-08-07", + "last_updated": "2025-08-07", + "type": "chat" + }, { "id": "openai/gpt-realtime-mini", "name": "GPT-Realtime mini", @@ -164281,6 +169386,90 @@ "last_updated": "2025-10-10", "type": "chat" }, + { + "id": "openai/gpt-5.6-sol-fast", + "name": "GPT 5.6 Sol (Fast)", + "display_name": "GPT 5.6 Sol (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, + { + "id": "openai/gpt-5.4-mini-fast", + "name": "GPT 5.4 Mini (Fast)", + "display_name": "GPT 5.4 Mini (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "type": "chat" + }, { "id": "openai/gpt-5.6-terra", "name": "GPT 5.6 Terra", @@ -164335,6 +169524,51 @@ "last_updated": "2026-07-09", "type": "chat" }, + { + "id": "openai/o3-fast", + "name": "o3 (Fast)", + "display_name": "o3 (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 100000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2024-05", + "release_date": "2025-04-16", + "last_updated": "2025-04-16", + "type": "chat" + }, { "id": "openai/gpt-5.4", "name": "GPT 5.4", @@ -164516,7 +169750,7 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2024-10", + "knowledge": "2025-08-31", "release_date": "2025-12-11", "last_updated": "2025-12-11", "type": "chat" @@ -164622,8 +169856,8 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2024-10", - "release_date": "2025-11-19", + "knowledge": "2024-09-30", + "release_date": "2025-11-13", "last_updated": "2025-11-13", "type": "chat" }, @@ -164674,11 +169908,41 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2024-10", - "release_date": "2025-11-12", + "knowledge": "2024-09-30", + "release_date": "2025-11-13", "last_updated": "2025-11-13", "type": "chat" }, + { + "id": "openai/gpt-4.1-fast", + "name": "GPT-4.1 (Fast)", + "display_name": "GPT-4.1 (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1047576, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2024-04", + "release_date": "2025-04-14", + "last_updated": "2025-04-14", + "type": "chat" + }, { "id": "openai/gpt-oss-safeguard-20b", "name": "gpt-oss-safeguard-20b", @@ -164708,6 +169972,37 @@ "last_updated": "2024-12-01", "type": "chat" }, + { + "id": "openai/gpt-5.6-terra-fast", + "name": "GPT 5.6 Terra (Fast)", + "display_name": "GPT 5.6 Terra (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "type": "chat" + }, { "id": "openai/gpt-realtime-2", "name": "gpt-realtime-2", @@ -164824,6 +170119,36 @@ "last_updated": "2026-03-17", "type": "chat" }, + { + "id": "openai/gpt-4.1-nano-fast", + "name": "GPT-4.1 nano (Fast)", + "display_name": "GPT-4.1 nano (Fast)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1047576, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2024-04", + "release_date": "2025-04-14", + "last_updated": "2025-04-14", + "type": "chat" + }, { "id": "openai/gpt-4.1-mini", "name": "GPT-4.1 mini", @@ -165629,7 +170954,7 @@ }, "attachment": false, "open_weights": false, - "release_date": "2026-06-22", + "release_date": "2026-05-30", "last_updated": "2026-05-30", "type": "chat" }, @@ -165918,7 +171243,7 @@ }, "attachment": true, "open_weights": false, - "release_date": "2026-05-20", + "release_date": "2026-04-16", "last_updated": "2026-04-16", "type": "chat" }, @@ -165953,7 +171278,7 @@ }, "attachment": true, "open_weights": false, - "release_date": "2026-04-30", + "release_date": "2026-04-17", "last_updated": "2026-04-17", "type": "chat" }, @@ -165981,7 +171306,7 @@ "default": true }, "attachment": false, - "open_weights": false, + "open_weights": true, "knowledge": "2025-01", "release_date": "2026-01-29", "last_updated": "2026-02-13", @@ -166011,9 +171336,9 @@ "default": true }, "attachment": true, - "open_weights": false, + "open_weights": true, "knowledge": "2026-03-01", - "release_date": "2026-05-28", + "release_date": "2026-05-29", "last_updated": "2026-05-29", "type": "chat" }, @@ -166068,7 +171393,7 @@ "default": true }, "attachment": true, - "open_weights": false, + "open_weights": true, "knowledge": "2024-12", "release_date": "2026-04-22", "last_updated": "2026-04-22", @@ -166097,7 +171422,7 @@ "default": true }, "attachment": true, - "open_weights": false, + "open_weights": true, "knowledge": "2024-12", "release_date": "2026-04-22", "last_updated": "2026-04-22", @@ -166421,7 +171746,7 @@ }, "attachment": true, "open_weights": false, - "release_date": "2026-02-24", + "release_date": "2026-02-23", "last_updated": "2026-02-23", "type": "chat" }, @@ -166504,9 +171829,9 @@ "default": true }, "attachment": false, - "open_weights": false, + "open_weights": true, "knowledge": "2025-09", - "release_date": "2025-07-22", + "release_date": "2026-02-03", "last_updated": "2026-02-03", "type": "chat" }, @@ -166611,7 +171936,7 @@ }, "attachment": true, "open_weights": false, - "release_date": "2026-08-02", + "release_date": "2026-07-19", "last_updated": "2026-07-19", "type": "chat" }, @@ -166738,7 +172063,7 @@ }, "attachment": true, "open_weights": false, - "release_date": "2026-07-28", + "release_date": "2026-07-15", "last_updated": "2026-07-15", "type": "chat" }, @@ -166929,8 +172254,8 @@ }, "attachment": false, "open_weights": true, - "knowledge": "2025-09", - "release_date": "2025-09-11", + "knowledge": "2025-04", + "release_date": "2025-09", "last_updated": "2025-09", "type": "chat" }, @@ -167078,7 +172403,7 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-04-22", "last_updated": "2026-04-22", "type": "chat" @@ -167107,7 +172432,7 @@ "attachment": false, "open_weights": true, "knowledge": "2025-04", - "release_date": "2025-09-11", + "release_date": "2025-09", "last_updated": "2025-09", "type": "chat" }, @@ -167118,15 +172443,16 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" ] }, "limit": { - "context": 262144, - "output": 262133 + "context": 1000000, + "output": 131072 }, "temperature": true, "tool_call": true, @@ -167135,7 +172461,7 @@ "default": true }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-08-14", "last_updated": "2026-08-14", "type": "chat" @@ -167258,7 +172584,7 @@ "supported": false }, "attachment": false, - "open_weights": true, + "open_weights": false, "knowledge": "2025-04", "release_date": "2025-07-23", "last_updated": "2025-07-23", @@ -167314,9 +172640,10 @@ "supported": false }, "attachment": true, - "open_weights": false, + "open_weights": true, + "knowledge": "2025-03-31", "release_date": "2025-09-23", - "last_updated": "2026-05-01", + "last_updated": "2025-09-23", "type": "chat" }, { @@ -167348,7 +172675,7 @@ }, "attachment": false, "open_weights": true, - "release_date": "2026-08-03", + "release_date": "2026-08-12", "last_updated": "2026-08-12", "type": "chat" }, @@ -167406,7 +172733,7 @@ }, "attachment": true, "open_weights": false, - "release_date": "2026-06-21", + "release_date": "2026-06-15", "last_updated": "2026-06-15", "type": "chat" }, @@ -167791,7 +173118,7 @@ "default": true }, "attachment": true, - "open_weights": false, + "open_weights": true, "release_date": "2026-07-30", "last_updated": "2026-07-30", "type": "chat" @@ -168754,8 +174081,8 @@ } }, "attachment": false, - "open_weights": false, - "release_date": "2026-02-12", + "open_weights": true, + "release_date": "2026-02-13", "last_updated": "2026-02-13", "type": "chat" }, @@ -168790,7 +174117,7 @@ }, "attachment": true, "open_weights": true, - "release_date": "2026-05-31", + "release_date": "2026-06-01", "last_updated": "2026-06-01", "type": "chat" }, @@ -168923,7 +174250,7 @@ } }, "attachment": false, - "open_weights": false, + "open_weights": true, "release_date": "2026-02-12", "last_updated": "2026-02-12", "type": "chat" @@ -168986,8 +174313,7 @@ } }, "attachment": false, - "open_weights": false, - "knowledge": "2024-10", + "open_weights": true, "release_date": "2025-12-23", "last_updated": "2025-12-23", "type": "chat" @@ -171529,6 +176855,39 @@ "last_updated": "2026-03-16", "type": "chat" }, + { + "id": "zai/glm-5.3", + "name": "GLM-5.3", + "display_name": "GLM-5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "zai/glm-5.2", "name": "GLM-5.2", @@ -173746,7 +179105,7 @@ ] }, "limit": { - "context": 8000, + "context": 1048576, "output": 128000 }, "temperature": true, @@ -174492,6 +179851,81 @@ "last_updated": "2025-01-25", "type": "chat" }, + { + "id": "qwen/qwen3-vl-235b-a22b-thinking", + "name": "Qwen3 VL 235B A22B Thinking", + "display_name": "Qwen3 VL 235B A22B Thinking", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "type": "chat" + }, + { + "id": "qwen/deepseek-v4-pro-0813", + "name": "DeepSeek V4 Pro 0813", + "display_name": "DeepSeek V4 Pro 0813", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, { "id": "qwen/qwen3-coder-next", "name": "Qwen3 Coder Next", @@ -174745,6 +180179,36 @@ "last_updated": "2025-09", "type": "chat" }, + { + "id": "qwen/qwen3.8-27b", + "name": "Qwen3.8 27B", + "display_name": "Qwen3.8 27B", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "qwen/qwq-plus", "name": "QwQ Plus", @@ -174847,6 +180311,36 @@ "last_updated": "2026-07-31", "type": "chat" }, + { + "id": "qwen/qwen3-vl-235b-a22b-instruct", + "name": "Qwen3 VL 235B A22B Instruct", + "display_name": "Qwen3 VL 235B A22B Instruct", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-03-31", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "type": "chat" + }, { "id": "qwen/qwen-vl-plus", "name": "Qwen-VL Plus", @@ -175656,7 +181150,7 @@ ] }, "limit": { - "context": 131072, + "context": 262144, "output": 16384 }, "temperature": true, @@ -176018,41 +181512,224 @@ "type": "chat" }, { - "id": "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731", - "name": "DeepSeek V4 Flash 0731", - "display_name": "DeepSeek V4 Flash 0731", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 384000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-05", - "release_date": "2026-07-31", - "last_updated": "2026-07-31", - "type": "chat" - }, - { - "id": "fireworks_ai/accounts/fireworks/models/gpt-oss-20b", + "id": "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731", + "name": "DeepSeek V4 Flash 0731", + "display_name": "DeepSeek V4 Flash 0731", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", + "type": "chat" + }, + { + "id": "fireworks_ai/accounts/fireworks/models/gpt-oss-20b", + "name": "GPT OSS 20B", + "display_name": "GPT OSS 20B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "type": "chat" + }, + { + "id": "perplexityai/sonar-deep-research", + "name": "Sonar Deep Research", + "display_name": "Sonar Deep Research", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 32768 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "knowledge": "2025-01", + "release_date": "2025-02-01", + "last_updated": "2025-09-01", + "type": "chat" + }, + { + "id": "perplexityai/sonar", + "name": "Sonar", + "display_name": "Sonar", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 127072, + "output": 4096 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-09-01", + "release_date": "2024-01-01", + "last_updated": "2025-09-01", + "type": "chat" + }, + { + "id": "perplexityai/sonar-reasoning-pro", + "name": "Sonar Reasoning Pro", + "display_name": "Sonar Reasoning Pro", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 4096 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-09-01", + "release_date": "2024-01-01", + "last_updated": "2025-09-01", + "type": "chat" + }, + { + "id": "perplexityai/sonar-pro", + "name": "Sonar Pro", + "display_name": "Sonar Pro", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 8192 + }, + "temperature": true, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-09-01", + "release_date": "2024-01-01", + "last_updated": "2025-09-01", + "type": "chat" + }, + { + "id": "groq/openai/gpt-oss-120b", + "name": "GPT OSS 120B", + "display_name": "GPT OSS 120B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2025-08-05", + "last_updated": "2025-08-05", + "type": "chat" + }, + { + "id": "groq/openai/gpt-oss-20b", "name": "GPT OSS 20B", "display_name": "GPT OSS 20B", "modalities": { @@ -176085,126 +181762,9 @@ "type": "chat" }, { - "id": "perplexityai/sonar-deep-research", - "name": "Sonar Deep Research", - "display_name": "Sonar Deep Research", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 128000, - "output": 32768 - }, - "temperature": false, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": false, - "knowledge": "2025-01", - "release_date": "2025-02-01", - "last_updated": "2025-09-01", - "type": "chat" - }, - { - "id": "perplexityai/sonar", - "name": "Sonar", - "display_name": "Sonar", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 127072, - "output": 4096 - }, - "temperature": true, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": true, - "open_weights": false, - "knowledge": "2025-09-01", - "release_date": "2024-01-01", - "last_updated": "2025-09-01", - "type": "chat" - }, - { - "id": "perplexityai/sonar-reasoning-pro", - "name": "Sonar Reasoning Pro", - "display_name": "Sonar Reasoning Pro", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 128000, - "output": 4096 - }, - "temperature": true, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": true, - "open_weights": false, - "knowledge": "2025-09-01", - "release_date": "2024-01-01", - "last_updated": "2025-09-01", - "type": "chat" - }, - { - "id": "perplexityai/sonar-pro", - "name": "Sonar Pro", - "display_name": "Sonar Pro", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 200000, - "output": 8192 - }, - "temperature": true, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": true, - "open_weights": false, - "knowledge": "2025-09-01", - "release_date": "2024-01-01", - "last_updated": "2025-09-01", - "type": "chat" - }, - { - "id": "groq/openai/gpt-oss-120b", - "name": "GPT OSS 120B", - "display_name": "GPT OSS 120B", + "id": "tensorx/deepseek/deepseek-v4-flash-0731", + "name": "DeepSeek V4 Flash 0731", + "display_name": "DeepSeek V4 Flash 0731", "modalities": { "input": [ "text" @@ -176214,8 +181774,8 @@ ] }, "limit": { - "context": 131072, - "output": 32768 + "context": 1048576, + "output": 384000 }, "temperature": true, "tool_call": true, @@ -176230,27 +181790,29 @@ }, "attachment": false, "open_weights": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", + "knowledge": "2025-05", + "release_date": "2026-07-31", + "last_updated": "2026-07-31", "type": "chat" }, { - "id": "groq/openai/gpt-oss-20b", - "name": "GPT OSS 20B", - "display_name": "GPT OSS 20B", + "id": "tensorx/moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "display_name": "Kimi K2.5", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 131072, - "output": 32768 + "context": 262144, + "output": 262144 }, - "temperature": true, + "temperature": false, "tool_call": true, "reasoning": { "supported": true, @@ -176258,13 +181820,20 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, - "attachment": false, + "attachment": true, "open_weights": true, - "release_date": "2025-08-05", - "last_updated": "2025-08-05", + "knowledge": "2025-01", + "release_date": "2026-01", + "last_updated": "2026-01", "type": "chat" }, { @@ -182125,6 +187694,34 @@ "last_updated": "2026-06-30", "type": "chat" }, + { + "id": "glm-5.2-fast", + "name": "GLM-5.2", + "display_name": "GLM-5.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "type": "chat" + }, { "id": "deepseek-v3.2", "name": "DeepSeek V3.2", @@ -184164,6 +189761,39 @@ "last_updated": "2025-04", "type": "chat" }, + { + "id": "glm-5.3", + "name": "GLM-5.3", + "display_name": "GLM-5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "grok-4-1-fast-non-reasoning", "name": "Grok 4.1 Fast Non-Reasoning", @@ -185846,34 +191476,6 @@ "last_updated": "2025-04-07", "type": "chat" }, - { - "id": "claude-3-opus", - "name": "Claude 3 Opus", - "display_name": "Claude 3 Opus", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 200000, - "output": 4096 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": true, - "open_weights": false, - "release_date": "2024-03-04", - "last_updated": "2024-03-04", - "type": "chat" - }, { "id": "llama-4-maverick-17b-instruct", "name": "Llama 4 Maverick 17B Instruct", @@ -189241,6 +194843,39 @@ "last_updated": "2026-07-31", "type": "chat" }, + { + "id": "deepseek-ai/DeepSeek-V4-Pro-0813", + "name": "DeepSeek V4 Pro 0813", + "display_name": "DeepSeek V4 Pro 0813", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-12", + "last_updated": "2026-08-12", + "type": "chat" + }, { "id": "Inferact/Qwen3.8-2.4T-A95B-NVFP4", "name": "Qwen3.8 2.4T A95B (NVFP4)", @@ -195247,7 +200882,7 @@ ] }, "limit": { - "context": 204800, + "context": 202752, "output": 131072 }, "temperature": true, @@ -195368,34 +201003,6 @@ "last_updated": "2025-08-11", "type": "chat" }, - { - "id": "z-ai/glm-5.2:free", - "name": "Z.ai: GLM 5.2 (free)", - "display_name": "Z.ai: GLM 5.2 (free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "temperature": true, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": false, - "open_weights": true, - "release_date": "2026-06-13", - "last_updated": "2026-06-13", - "type": "chat" - }, { "id": "z-ai/glm-5", "name": "GLM-5", @@ -195468,6 +201075,39 @@ "last_updated": "2026-03-16", "type": "chat" }, + { + "id": "z-ai/glm-5.3", + "name": "GLM-5.3", + "display_name": "GLM-5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "z-ai/glm-5.2", "name": "GLM-5.2", @@ -195482,7 +201122,7 @@ }, "limit": { "context": 1048576, - "output": 262144 + "output": 131072 }, "temperature": true, "tool_call": true, @@ -195659,8 +201299,8 @@ }, { "id": "deepseek/deepseek-v3.1-terminus", - "name": "DeepSeek: DeepSeek V3.1 Terminus (retires Aug 17)", - "display_name": "DeepSeek: DeepSeek V3.1 Terminus (retires Aug 17)", + "name": "DeepSeek: DeepSeek V3.1 Terminus", + "display_name": "DeepSeek: DeepSeek V3.1 Terminus", "modalities": { "input": [ "text" @@ -195671,7 +201311,7 @@ }, "limit": { "context": 163840, - "output": 32768 + "output": 163840 }, "temperature": true, "tool_call": true, @@ -195789,7 +201429,7 @@ ] }, "limit": { - "context": 1048576, + "context": 1048575, "output": 384000 }, "temperature": true, @@ -195823,7 +201463,7 @@ }, "limit": { "context": 1048576, - "output": 384000 + "output": 393216 }, "temperature": true, "tool_call": true, @@ -196030,7 +201670,7 @@ }, "limit": { "context": 163840, - "output": 65536 + "output": 163840 }, "temperature": true, "tool_call": true, @@ -196487,7 +202127,7 @@ }, "limit": { "context": 65536, - "output": 66000 + "output": 65536 }, "temperature": true, "tool_call": false, @@ -196703,7 +202343,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 }, "temperature": true, "tool_call": true, @@ -198247,33 +203887,6 @@ "last_updated": "2026-08-12", "type": "chat" }, - { - "id": "ai21/jamba-large-1.7", - "name": "AI21: Jamba Large 1.7", - "display_name": "AI21: Jamba Large 1.7", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 256000, - "output": 4096 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "release_date": "2025-08-08", - "last_updated": "2025-08-08", - "type": "chat" - }, { "id": "moonshotai/kimi-k2.7-code", "name": "Kimi K2.7 Code", @@ -200158,6 +205771,36 @@ "last_updated": "2025-08-07", "type": "chat" }, + { + "id": "openai/gpt-5.6-sol-discounted", + "name": "OpenAI: GPT-5.6 Sol (50% off)", + "display_name": "OpenAI: GPT-5.6 Sol (50% off)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2025-08-26", + "last_updated": "2025-08-26", + "type": "chat" + }, { "id": "openai/o4-mini-high", "name": "OpenAI: o4 Mini High", @@ -202612,7 +208255,7 @@ }, "limit": { "context": 262144, - "output": 65536 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -202639,8 +208282,8 @@ }, { "id": "qwen/qwen3-vl-235b-a22b-thinking", - "name": "Qwen: Qwen3 VL 235B A22B Thinking", - "display_name": "Qwen: Qwen3 VL 235B A22B Thinking", + "name": "Qwen3 VL 235B A22B Thinking", + "display_name": "Qwen3 VL 235B A22B Thinking", "modalities": { "input": [ "text", @@ -202672,7 +208315,8 @@ } }, "attachment": true, - "open_weights": false, + "open_weights": true, + "knowledge": "2025-03-31", "release_date": "2025-09-23", "last_updated": "2025-09-23", "type": "chat" @@ -203491,8 +209135,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 262144, + "output": 262144 }, "temperature": true, "tool_call": true, @@ -203531,7 +209175,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 }, "temperature": true, "tool_call": true, @@ -203794,7 +209438,7 @@ }, "limit": { "context": 262144, - "output": 81920 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -203877,8 +209521,8 @@ }, { "id": "qwen/qwen3-vl-235b-a22b-instruct", - "name": "Qwen: Qwen3 VL 235B A22B Instruct", - "display_name": "Qwen: Qwen3 VL 235B A22B Instruct", + "name": "Qwen3 VL 235B A22B Instruct", + "display_name": "Qwen3 VL 235B A22B Instruct", "modalities": { "input": [ "text", @@ -203898,7 +209542,8 @@ "supported": false }, "attachment": true, - "open_weights": false, + "open_weights": true, + "knowledge": "2025-03-31", "release_date": "2025-09-23", "last_updated": "2025-09-23", "type": "chat" @@ -204173,8 +209818,8 @@ }, { "id": "sakana/sakana-namazu", - "name": "Sakana: Sakana Namazu", - "display_name": "Sakana: Sakana Namazu", + "name": "Sakana Namazu", + "display_name": "Sakana Namazu", "modalities": { "input": [ "text", @@ -204197,8 +209842,8 @@ }, "attachment": true, "open_weights": false, - "release_date": "2026-08-11", - "last_updated": "2026-08-11", + "release_date": "2026-08-03", + "last_updated": "2026-08-03", "type": "chat" }, { @@ -204593,59 +210238,6 @@ "last_updated": "2025-08-26", "type": "chat" }, - { - "id": "stealth/gpt-5.6-sol", - "name": "Stealth: GPT-5.6 Sol (20% off)", - "display_name": "Stealth: GPT-5.6 Sol (20% off)", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1050000, - "output": 128000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "none", - "low", - "medium", - "high", - "xhigh", - "max" - ], - "verbosity": "medium", - "verbosity_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "attachment": true, - "open_weights": false, - "release_date": "2025-08-26", - "last_updated": "2025-08-26", - "type": "chat" - }, { "id": "stealth/claude-sonnet-4.6", "name": "Stealth: Claude Sonnet 4.6 (20% off)", @@ -204947,8 +210539,8 @@ }, { "id": "inclusionai/ring-2.6-1t", - "name": "inclusionAI: Ring-2.6-1T", - "display_name": "inclusionAI: Ring-2.6-1T", + "name": "inclusionAI: Ring-2.6-1T (retires Aug 24)", + "display_name": "inclusionAI: Ring-2.6-1T (retires Aug 24)", "modalities": { "input": [ "text" @@ -205008,8 +210600,8 @@ }, { "id": "inclusionai/ling-2.6-1t", - "name": "inclusionAI: Ling-2.6-1T", - "display_name": "inclusionAI: Ling-2.6-1T", + "name": "inclusionAI: Ling-2.6-1T (retires Aug 24)", + "display_name": "inclusionAI: Ling-2.6-1T (retires Aug 24)", "modalities": { "input": [ "text" @@ -205035,8 +210627,8 @@ }, { "id": "inclusionai/ling-2.6-flash", - "name": "inclusionAI: Ling-2.6-flash", - "display_name": "inclusionAI: Ling-2.6-flash", + "name": "inclusionAI: Ling-2.6-flash (retires Aug 24)", + "display_name": "inclusionAI: Ling-2.6-flash (retires Aug 24)", "modalities": { "input": [ "text" @@ -206159,33 +211751,6 @@ "last_updated": "2025-06-30", "type": "chat" }, - { - "id": "mancer/weaver", - "name": "Mancer: Weaver (alpha)", - "display_name": "Mancer: Weaver (alpha)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 8000, - "output": 6000 - }, - "temperature": true, - "tool_call": false, - "reasoning": { - "supported": false - }, - "attachment": false, - "open_weights": false, - "release_date": "2023-08-02", - "last_updated": "2023-08-02", - "type": "chat" - }, { "id": "sao10k/l3.1-euryale-70b", "name": "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -206634,7 +212199,7 @@ ] }, "limit": { - "context": 196608, + "context": 65536, "output": 196608 }, "temperature": true, @@ -206850,8 +212415,8 @@ ] }, "limit": { - "context": 1000000, - "output": 65536 + "context": 991000, + "output": 64000 }, "temperature": true, "tool_call": true, @@ -207080,8 +212645,8 @@ ] }, "limit": { - "context": 1048576, - "output": 65536 + "context": 1000000, + "output": 64000 }, "temperature": true, "tool_call": true, @@ -207464,8 +213029,8 @@ ] }, "limit": { - "context": 262144, - "output": 65536 + "context": 240000, + "output": 64000 }, "temperature": true, "tool_call": true, @@ -207564,7 +213129,7 @@ }, "limit": { "context": 1000000, - "output": 65536 + "output": 64000 }, "temperature": true, "tool_call": true, @@ -207636,8 +213201,8 @@ ] }, "limit": { - "context": 1048576, - "output": 65536 + "context": 1000000, + "output": 64000 }, "temperature": true, "tool_call": true, @@ -208209,8 +213774,8 @@ ] }, "limit": { - "context": 1048576, - "output": 65535 + "context": 1000000, + "output": 65000 }, "temperature": true, "tool_call": true, @@ -208347,7 +213912,7 @@ }, "limit": { "context": 1000000, - "output": 65536 + "output": 64000 }, "temperature": true, "tool_call": true, @@ -208496,7 +214061,7 @@ ] }, "limit": { - "context": 262144, + "context": 256000, "output": 131072 }, "temperature": true, @@ -208766,7 +214331,7 @@ }, "limit": { "context": 400000, - "output": 128000 + "output": 131072 }, "temperature": false, "tool_call": true, @@ -208864,7 +214429,7 @@ }, "limit": { "context": 1050000, - "output": 131072 + "output": 131000 }, "temperature": true, "tool_call": true, @@ -208904,7 +214469,7 @@ }, "limit": { "context": 204800, - "output": 131072 + "output": 131000 }, "temperature": true, "tool_call": true, @@ -209100,7 +214665,7 @@ ] }, "limit": { - "context": 1000000, + "context": 256000, "output": 32000 }, "temperature": true, @@ -209461,7 +215026,7 @@ ] }, "limit": { - "context": 262144, + "context": 256000, "output": 32768 }, "temperature": false, @@ -209544,7 +215109,7 @@ ] }, "limit": { - "context": 262144, + "context": 262000, "output": 131072 }, "temperature": true, @@ -209940,7 +215505,7 @@ }, "limit": { "context": 131072, - "output": 16384 + "output": 8192 }, "temperature": true, "tool_call": true, @@ -210045,8 +215610,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 40960, + "output": 40960 }, "temperature": true, "tool_call": true, @@ -210124,8 +215689,8 @@ ] }, "limit": { - "context": 262144, - "output": 131072 + "context": 32768, + "output": 32768 }, "temperature": true, "tool_call": true, @@ -210407,7 +215972,7 @@ ] }, "limit": { - "context": 202752, + "context": 202000, "output": 131072 }, "temperature": true, @@ -210446,7 +216011,7 @@ ] }, "limit": { - "context": 1048576, + "context": 1040000, "output": 128000 }, "temperature": true, @@ -210581,8 +216146,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 16384, + "output": 16384 }, "temperature": true, "tool_call": true, @@ -210649,8 +216214,8 @@ ] }, "limit": { - "context": 131072, - "output": 16384 + "context": 128000, + "output": 8192 }, "temperature": true, "tool_call": true, @@ -210823,7 +216388,7 @@ ] }, "limit": { - "context": 131072, + "context": 128000, "output": 128000 }, "temperature": true, @@ -210879,8 +216444,8 @@ ] }, "limit": { - "context": 262144, - "output": 65536 + "context": 32000, + "output": 32000 }, "temperature": true, "tool_call": true, @@ -213073,6 +218638,34 @@ "last_updated": "2026-06-11", "type": "chat" }, + { + "id": "z-ai-glm-5-3", + "name": "GLM 5.3", + "display_name": "GLM 5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-18", + "last_updated": "2026-08-18", + "type": "chat" + }, { "id": "qwen-3-7-max", "name": "Qwen 3.7 Max", @@ -213917,6 +219510,36 @@ "last_updated": "2026-06-11", "type": "chat" }, + { + "id": "qwen-3-8-27b", + "name": "Qwen 3.8 27B", + "display_name": "Qwen 3.8 27B", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-08-17", + "last_updated": "2026-08-18", + "type": "chat" + }, { "id": "grok-4-5", "name": "Grok 4.5", @@ -216231,6 +221854,35 @@ "last_updated": "2025-07-22", "type": "chat" }, + { + "id": "Qwen/Qwen3.8-27B", + "name": "Qwen3.8 27B", + "display_name": "Qwen3.8 27B", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "zai-org/GLM-5.1", "name": "GLM 5.1", @@ -239752,6 +245404,26 @@ "name": "PPInfra", "display_name": "PPInfra", "models": [ + { + "id": "zai-org/glm-5.3", + "name": "GLM 5.3", + "display_name": "GLM 5.3", + "limit": { + "context": 1048576, + "output": 131072 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "deepseek/deepseek-v4-pro-0813", "name": "DeepSeek V4 Pro 0813", @@ -246348,6 +252020,25 @@ }, "type": "chat" }, + { + "id": "z-ai/glm-5.3", + "name": "z-ai/glm-5.3", + "display_name": "z-ai/glm-5.3", + "limit": { + "context": 4096, + "output": 4096 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "z-ai/glm-5v-turbo", "name": "z-ai/glm-5v-turbo", @@ -248394,6 +254085,32 @@ }, "type": "chat" }, + { + "id": "gpt-5.6-sol-disc", + "name": "gpt-5.6-sol-disc", + "display_name": "gpt-5.6-sol-disc", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 1050000, + "output": 1050000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "doubao-seedance-2-5-260628", "name": "doubao-seedance-2-5-260628", @@ -249671,7 +255388,9 @@ "display_name": "happyhorse-1.1-i2v", "modalities": { "input": [ - "text" + "text", + "video", + "image" ] }, "limit": { @@ -249690,7 +255409,8 @@ "display_name": "happyhorse-1.1-r2v", "modalities": { "input": [ - "text" + "text", + "video" ] }, "limit": { @@ -249709,7 +255429,8 @@ "display_name": "happyhorse-1.1-t2v", "modalities": { "input": [ - "text" + "text", + "video" ] }, "limit": { @@ -250444,7 +256165,9 @@ "display_name": "happyhorse-1.0-i2v", "modalities": { "input": [ - "text" + "text", + "image", + "video" ] }, "limit": { @@ -250463,7 +256186,8 @@ "display_name": "happyhorse-1.0-r2v", "modalities": { "input": [ - "text" + "text", + "video" ] }, "limit": { @@ -250482,7 +256206,8 @@ "display_name": "happyhorse-1.0-t2v", "modalities": { "input": [ - "text" + "text", + "video" ] }, "limit": { @@ -250501,7 +256226,9 @@ "display_name": "happyhorse-1.0-video-edit", "modalities": { "input": [ - "text" + "text", + "image", + "video" ] }, "limit": { @@ -266479,28 +272206,6 @@ "name": "OpenRouter", "display_name": "OpenRouter", "models": [ - { - "id": "ai21/jamba-large-1.7", - "name": "AI21: Jamba Large 1.7", - "display_name": "AI21: Jamba Large 1.7", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 256000, - "output": 4096 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "aion-labs/aion-2.0", "name": "AionLabs: Aion-2.0", @@ -268442,7 +274147,7 @@ }, "limit": { "context": 163840, - "output": 65536 + "output": 163840 }, "tool_call": true, "reasoning": { @@ -268583,7 +274288,7 @@ }, "limit": { "context": 163840, - "output": 32768 + "output": 163840 }, "tool_call": true, "reasoning": { @@ -268732,7 +274437,7 @@ }, "limit": { "context": 1048576, - "output": 384000 + "output": 393216 }, "temperature": true, "tool_call": true, @@ -268776,8 +274481,8 @@ ] }, "limit": { - "context": 1048576, - "output": 384000 + "context": 1048575, + "output": 1048575 }, "temperature": true, "tool_call": true, @@ -269493,7 +275198,7 @@ }, "limit": { "context": 65536, - "output": 66000 + "output": 65536 }, "tool_call": false, "reasoning": { @@ -270214,7 +275919,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 }, "temperature": true, "tool_call": true, @@ -270576,28 +276281,6 @@ }, "type": "chat" }, - { - "id": "mancer/weaver", - "name": "Mancer: Weaver (alpha)", - "display_name": "Mancer: Weaver (alpha)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 8000, - "output": 6000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "meituan/longcat-2.0", "name": "Meituan: LongCat 2.0", @@ -271083,8 +276766,8 @@ ] }, "limit": { - "context": 196608, - "output": 196608 + "context": 65536, + "output": 65536 }, "temperature": true, "tool_call": true, @@ -276503,7 +282186,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 }, "tool_call": true, "reasoning": { @@ -276765,7 +282448,7 @@ }, "limit": { "context": 262144, - "output": 81920 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -276839,7 +282522,7 @@ }, "limit": { "context": 262144, - "output": 65536 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -277056,8 +282739,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 262144, + "output": 262144 }, "tool_call": true, "reasoning": { @@ -278287,7 +283970,7 @@ ] }, "limit": { - "context": 204800, + "context": 202752, "output": 131072 }, "temperature": true, @@ -278500,7 +284183,7 @@ }, "limit": { "context": 1048576, - "output": 262144 + "output": 131072 }, "temperature": true, "tool_call": true, @@ -278552,15 +284235,44 @@ ] }, "limit": { - "context": 128000, - "output": 128000 + "context": 256000, + "output": 256000 }, "temperature": true, - "tool_call": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, + { + "id": "z-ai/glm-5.3", + "name": "Z.ai: GLM 5.3", + "display_name": "Z.ai: GLM 5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "temperature": true, + "tool_call": true, "reasoning": { "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, "type": "chat" }, { @@ -278646,6 +284358,34 @@ }, "type": "chat" }, + { + "id": "zai-org/glm-5.3", + "name": "GLM 5.3", + "display_name": "GLM 5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "deepseek/deepseek-v4-pro-0813", "name": "DeepSeek V4 Pro 0813", @@ -285499,46 +291239,6 @@ "last_updated": "2026-04-24", "type": "chat" }, - { - "id": "deepseek/deepseek-v4-flash-free", - "name": "DeepSeek: DeepSeek V4 Flash 0731 (Free)", - "display_name": "DeepSeek: DeepSeek V4 Flash 0731 (Free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 128000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "knowledge": "2025-05", - "release_date": "2026-04-24", - "last_updated": "2026-04-24", - "type": "chat" - }, { "id": "deepseek/deepseek-v4-pro", "name": "DeepSeek: DeepSeek V4 Pro 0813", @@ -285616,6 +291316,32 @@ "last_updated": "2025-09-29", "type": "chat" }, + { + "id": "dots-studio/dots3-note-prev", + "name": "Dots Studio: Dots3-Note Preview (Free)", + "display_name": "Dots Studio: Dots3-Note Preview (Free)", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 393100, + "output": 393100 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, { "id": "google/gemini-2.5-flash", "name": "Google: Gemini 2.5 Flash", @@ -286129,7 +291855,7 @@ "attachment": false, "open_weights": false, "knowledge": "2025-11", - "release_date": "2026-03-10", + "release_date": "2026-04-22", "last_updated": "2026-04-22", "type": "embedding" }, @@ -286266,7 +291992,6 @@ "context": 262144, "output": 32768 }, - "temperature": true, "tool_call": true, "reasoning": { "supported": true, @@ -286279,8 +292004,8 @@ }, "attachment": false, "open_weights": false, - "release_date": "2026-08-06", - "last_updated": "2026-08-06", + "release_date": "2026-08-05", + "last_updated": "2026-08-05", "type": "chat" }, { @@ -286571,6 +292296,52 @@ "last_updated": "2026-08-05", "type": "chat" }, + { + "id": "mindai/macaron-v1-tall", + "name": "Mind Lab: Macaron V1 Tall", + "display_name": "Mind Lab: Macaron V1 Tall", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, + { + "id": "mindai/macaron-v1-venti", + "name": "Mind Lab: Macaron V1 Venti", + "display_name": "Mind Lab: Macaron V1 Venti", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 1000000 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, { "id": "minimax/minimax-m2", "name": "MiniMax: MiniMax M2", @@ -290080,6 +295851,72 @@ "last_updated": "2026-06-13", "type": "chat" }, + { + "id": "z-ai/glm-5.3", + "name": "Z.AI: GLM 5.3", + "display_name": "Z.AI: GLM 5.3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, + { + "id": "z-ai/glm-5.3-free", + "name": "Z.AI: GLM 5.3 (Free)", + "display_name": "Z.AI: GLM 5.3 (Free)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-08-14", + "last_updated": "2026-08-14", + "type": "chat" + }, { "id": "z-ai/glm-5v-turbo", "name": "Z.AI: GLM 5V Turbo", diff --git a/src/main/agent/acp/auth/acpAuthService.ts b/src/main/agent/acp/auth/acpAuthService.ts new file mode 100644 index 0000000000..eacf1a6e65 --- /dev/null +++ b/src/main/agent/acp/auth/acpAuthService.ts @@ -0,0 +1,232 @@ +import type { AcpAgentConfig, AcpAuthChallenge, AcpAuthRunStatus } from '@shared/types/acp' +import type { AgentSettingsPort } from '@/agent/settings' +import type { AcpRuntimeOwner } from '../client' +import { AcpTerminalAuthRunner } from './acpTerminalAuthRunner' +import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' + +type AcpAuthEventName = 'acpAuth.output' | 'acpAuth.stateChanged' + +export interface AcpAuthServiceDependencies { + owner: AcpRuntimeOwner + agentSettings: Pick + sendToRenderer(webContentsId: number, name: AcpAuthEventName, payload: unknown): void + onRendererDestroyed(webContentsId: number, callback: () => void): () => void +} + +export class AcpAuthService { + private readonly runner = new AcpTerminalAuthRunner() + private readonly statuses = new Map() + private readonly detachRendererListeners = new Map void>() + + constructor(private readonly dependencies: AcpAuthServiceDependencies) {} + + async inspect(agentId: string, workdir?: string): Promise { + const agent = await this.resolveAgent(agentId) + const challenge = await this.dependencies.owner + .getOrCreate() + .processManager.inspectAuthentication(agent, workdir) + this.statuses.set(challenge.id, { + challengeId: challenge.id, + state: 'required' + }) + return challenge + } + + async start( + challengeId: string, + methodId: string, + ownerWebContentsId: number + ): Promise { + const current = this.statuses.get(challengeId) + if (current?.state === 'running' || current?.state === 'reconnecting') { + if (current.ownerWebContentsId !== ownerWebContentsId) { + throw new Error('ACP authentication is owned by another renderer') + } + return this.publicStatus(current) + } + + const processManager = this.dependencies.owner.getOrCreate().processManager + const challenge = processManager.getAuthChallenge(challengeId) + const method = challenge.methods.find((candidate) => candidate.id === methodId) + if (!method) throw new Error('ACP authentication method is unavailable') + if (method.type === 'unsupported') { + throw new Error('ACP authentication method is not supported by DeepChat') + } + + if (method.type === 'agent') { + this.setStatus(ownerWebContentsId, { challengeId, state: 'running' }) + try { + await processManager.authenticateAgent(challengeId, methodId) + return this.setStatus(ownerWebContentsId, { challengeId, state: 'succeeded' }) + } catch (error) { + return this.setStatus(ownerWebContentsId, { + challengeId, + state: 'failed', + error: this.toSafeError(error) + }) + } + } + + const prepared = await processManager.prepareTerminalAuthentication(challengeId, methodId) + let started: ReturnType + try { + started = this.runner.start({ + ownerWebContentsId, + launch: prepared.launch, + onData: (runId, data) => { + this.dependencies.sendToRenderer(ownerWebContentsId, 'acpAuth.output', { + challengeId, + runId, + data, + version: Date.now() + }) + } + }) + } catch (error) { + processManager.abandonAuthentication(challengeId) + return this.setStatus(ownerWebContentsId, { + challengeId, + state: 'failed', + error: this.toSafeError(error) + }) + } + + const status = this.setStatus(ownerWebContentsId, { + challengeId, + runId: started.runId, + state: 'running' + }) + this.detachRendererListeners.set( + started.runId, + this.dependencies.onRendererDestroyed(ownerWebContentsId, () => { + this.runner.cancel(started.runId, ownerWebContentsId) + }) + ) + void this.finishTerminalAuthentication( + challengeId, + started.runId, + ownerWebContentsId, + started.completion + ) + return status + } + + getStatus(challengeId: string, ownerWebContentsId: number): AcpAuthRunStatus { + const status = this.statuses.get(challengeId) + if (!status) return { challengeId, state: 'required' } + if ( + status.ownerWebContentsId !== undefined && + status.ownerWebContentsId !== ownerWebContentsId + ) { + throw new Error('ACP authentication is owned by another renderer') + } + return this.publicStatus(status) + } + + write(runId: string, ownerWebContentsId: number, data: string): void { + this.runner.write(runId, ownerWebContentsId, data) + } + + cancel(runId: string, ownerWebContentsId: number): boolean { + return this.runner.cancel(runId, ownerWebContentsId) + } + + shutdown(): void { + this.runner.shutdown() + this.detachRendererListeners.forEach((detach) => detach()) + this.detachRendererListeners.clear() + } + + private async finishTerminalAuthentication( + challengeId: string, + runId: string, + ownerWebContentsId: number, + completion: Promise<{ exitCode: number; signal?: number; cancelled: boolean }> + ): Promise { + const processManager = this.dependencies.owner.getOrCreate().processManager + try { + const exit = await completion + if (exit.cancelled) { + processManager.abandonAuthentication(challengeId) + this.setStatus(ownerWebContentsId, { + challengeId, + runId, + state: 'cancelled' + }) + return + } + if (exit.signal) { + processManager.abandonAuthentication(challengeId) + this.setStatus(ownerWebContentsId, { + challengeId, + runId, + state: 'failed', + error: `Authentication process terminated by signal ${exit.signal}` + }) + return + } + if (exit.exitCode !== 0) { + processManager.abandonAuthentication(challengeId) + this.setStatus(ownerWebContentsId, { + challengeId, + runId, + state: 'failed', + error: `Authentication process exited with code ${exit.exitCode}` + }) + return + } + + this.setStatus(ownerWebContentsId, { + challengeId, + runId, + state: 'reconnecting' + }) + await processManager.completeTerminalAuthentication(challengeId) + this.setStatus(ownerWebContentsId, { + challengeId, + runId, + state: 'succeeded' + }) + } catch (error) { + processManager.abandonAuthentication(challengeId) + this.setStatus(ownerWebContentsId, { + challengeId, + runId, + state: 'failed', + error: this.toSafeError(error) + }) + } finally { + this.detachRendererListeners.get(runId)?.() + this.detachRendererListeners.delete(runId) + } + } + + private async resolveAgent(agentId: string): Promise { + const canonicalId = resolveAcpAgentAlias(agentId) + const agent = (await this.dependencies.agentSettings.getAcpAgents()).find( + (candidate) => candidate.id === canonicalId + ) + if (!agent) throw new Error(`ACP agent not found: ${canonicalId}`) + return agent + } + + private setStatus(ownerWebContentsId: number, status: AcpAuthRunStatus): AcpAuthRunStatus { + this.statuses.set(status.challengeId, { ...status, ownerWebContentsId }) + this.dependencies.sendToRenderer(ownerWebContentsId, 'acpAuth.stateChanged', { + ...status, + version: Date.now() + }) + return status + } + + private publicStatus( + status: AcpAuthRunStatus & { ownerWebContentsId?: number } + ): AcpAuthRunStatus { + const { ownerWebContentsId: _ownerWebContentsId, ...result } = status + return result + } + + private toSafeError(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 1_000) + } +} diff --git a/src/main/agent/acp/auth/acpTerminalAuthRunner.ts b/src/main/agent/acp/auth/acpTerminalAuthRunner.ts new file mode 100644 index 0000000000..9dc113e7b6 --- /dev/null +++ b/src/main/agent/acp/auth/acpTerminalAuthRunner.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto' +import { spawn, type IPty } from 'node-pty' +import type { AcpMaterializedLaunch } from '../runtime/acpProcessManager' + +export interface AcpTerminalAuthExit { + exitCode: number + signal?: number + cancelled: boolean +} + +interface AcpTerminalAuthRun { + ownerWebContentsId: number + pty: IPty + cancelled: boolean + completion: Promise +} + +export class AcpTerminalAuthRunner { + private readonly runs = new Map() + + start(input: { + ownerWebContentsId: number + launch: AcpMaterializedLaunch + onData(runId: string, data: string): void + }): { runId: string; completion: Promise } { + const runId = randomUUID() + const pty = spawn(input.launch.command, input.launch.args, { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: input.launch.cwd, + env: input.launch.env + }) + let resolveExit!: (exit: AcpTerminalAuthExit) => void + const completion = new Promise((resolve) => { + resolveExit = resolve + }) + const run: AcpTerminalAuthRun = { + ownerWebContentsId: input.ownerWebContentsId, + pty, + cancelled: false, + completion + } + this.runs.set(runId, run) + const dataSubscription = pty.onData((data) => { + for (let offset = 0; offset < data.length; offset += 65_536) { + input.onData(runId, data.slice(offset, offset + 65_536)) + } + }) + const exitSubscription = pty.onExit(({ exitCode, signal }) => { + dataSubscription.dispose() + exitSubscription.dispose() + this.runs.delete(runId) + resolveExit({ exitCode, signal, cancelled: run.cancelled }) + }) + return { runId, completion } + } + + write(runId: string, ownerWebContentsId: number, data: string): void { + const run = this.requireOwnedRun(runId, ownerWebContentsId) + run.pty.write(data) + } + + cancel(runId: string, ownerWebContentsId: number): boolean { + const run = this.runs.get(runId) + if (!run) return false + if (run.ownerWebContentsId !== ownerWebContentsId) { + throw new Error('ACP authentication terminal belongs to another renderer') + } + run.cancelled = true + run.pty.kill() + return true + } + + cancelOwnedBy(ownerWebContentsId: number): void { + for (const [runId, run] of this.runs) { + if (run.ownerWebContentsId === ownerWebContentsId) { + this.cancel(runId, ownerWebContentsId) + } + } + } + + shutdown(): void { + for (const run of this.runs.values()) { + run.cancelled = true + run.pty.kill() + } + } + + private requireOwnedRun(runId: string, ownerWebContentsId: number): AcpTerminalAuthRun { + const run = this.runs.get(runId) + if (!run) throw new Error('ACP authentication terminal is not running') + if (run.ownerWebContentsId !== ownerWebContentsId) { + throw new Error('ACP authentication terminal belongs to another renderer') + } + return run + } +} diff --git a/src/main/agent/acp/client/connection/AcpConnectionManager.ts b/src/main/agent/acp/client/connection/AcpConnectionManager.ts index e6117d6b11..0e86df0bc9 100644 --- a/src/main/agent/acp/client/connection/AcpConnectionManager.ts +++ b/src/main/agent/acp/client/connection/AcpConnectionManager.ts @@ -21,7 +21,8 @@ export class AcpConnectionManager { resolveLaunchSpec: (agentId, workdir) => agentSettings.resolveAcpLaunchSpec(agentId, workdir), getAgentState: (agentId) => agentSettings.getAcpAgentState(agentId), getNpmRegistry: async () => registry.getNpmRegistry(), - getUvRegistry: async () => registry.getUvRegistry() + getUvRegistry: async () => registry.getUvRegistry(), + terminalAuthAvailable: true }) } diff --git a/src/main/agent/acp/instance/acpAgentRuntime.ts b/src/main/agent/acp/instance/acpAgentRuntime.ts index b27b7b2bae..eb4447058f 100644 --- a/src/main/agent/acp/instance/acpAgentRuntime.ts +++ b/src/main/agent/acp/instance/acpAgentRuntime.ts @@ -10,6 +10,7 @@ import type { AcpClientRuntime, AcpRuntimeOwner } from '@/agent/acp/client' import type { SessionPendingInputRuntimePort } from '@/session/data/contracts' import { AcpAgentInstance, type AcpAgentInstanceDependencies } from './acpAgentInstance' import type { AcpAgentSnapshot, AcpInstanceScope } from './ports' +import { isAcpAuthenticationRequiredError } from '../runtime/acpAuthentication' export interface AcpAgentRuntimeSessionInput { sessionId: AppSessionId @@ -148,6 +149,7 @@ export class AcpAgentRuntime { } return instance } catch (error) { + if (isAcpAuthenticationRequiredError(error)) throw error try { await instance.close() } catch (closeError) { diff --git a/src/main/agent/acp/launch/acpInitHelper.ts b/src/main/agent/acp/launch/acpInitHelper.ts deleted file mode 100644 index 6934f1d73a..0000000000 --- a/src/main/agent/acp/launch/acpInitHelper.ts +++ /dev/null @@ -1,703 +0,0 @@ -import logger from '@shared/logger' -import * as path from 'path' -import * as fs from 'fs' -import { exec } from 'child_process' -import { promisify } from 'util' -import { type WebContents, app } from 'electron' -import type { AcpBuiltinAgentId } from '@shared/types/acp' -import type { AcpAgentConfig, AcpAgentProfile } from '@shared/types/acp' -import { spawn } from 'node-pty' -import type { IPty } from 'node-pty' -import { RuntimeHelper } from '@/lib/runtimeHelper' -import { - getPathEntriesFromEnv, - getShellEnvironment, - mergeCommandEnvironment, - setPathEntriesOnEnv -} from '@/agent/shared/process/shellEnvHelper' -import { DEEPCHAT_EVENT_CHANNEL } from '@shared/contracts/channels' -import { createDeepchatEventEnvelope } from '@shared/contracts/events' - -const execAsync = promisify(exec) - -interface InitCommandConfig { - commands: string[] - description: string -} - -interface ExternalDependency { - name: string - description: string - platform?: string[] - checkCommand?: string - checkPaths?: string[] - installCommands?: { - winget?: string - chocolatey?: string - scoop?: string - } - downloadUrl?: string - requiredFor?: string[] -} - -const EXTERNAL_DEPENDENCIES: ExternalDependency[] = [ - { - name: 'Git Bash', - description: 'Git for Windows includes Git Bash', - platform: ['win32'], - checkCommand: 'git --version', - checkPaths: [ - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Program Files (x86)\\Git\\bin\\bash.exe' - ], - installCommands: { - winget: 'winget install Git.Git', - chocolatey: 'choco install git', - scoop: 'scoop install git' - }, - downloadUrl: 'https://git-scm.com/download/win', - requiredFor: ['claude-code-acp'] - } -] - -const BUILTIN_INIT_COMMANDS: Record = { - 'kimi-cli': { - commands: ['uv tool install --python 3.13 kimi-cli', 'kimi acp'], - description: 'Initialize Kimi CLI' - }, - 'claude-code-acp': { - commands: [ - 'npm i -g @zed-industries/claude-code-acp', - 'npm install -g @anthropic-ai/claude-code', - 'claude' - ], - description: 'Initialize Claude Code ACP' - }, - 'codex-acp': { - commands: ['npm i -g @zed-industries/codex-acp', 'npm install -g @openai/codex', 'codex'], - description: 'Initialize Codex CLI ACP' - }, - 'dimcode-acp': { - commands: ['npm i -g dimcode', 'dim'], - description: 'Initialize DimCode ACP' - } -} - -class AcpInitHelper { - private activeShell: IPty | null = null - private readonly runtimeHelper = RuntimeHelper.getInstance() - - constructor() { - this.runtimeHelper.initializeRuntimes() - } - - /** - * Get or create the temporary directory for ACP agent shell sessions - */ - private getAcpTempDir(): string { - const userDataPath = app.getPath('userData') - const acpTempDir = path.join(userDataPath, 'temp', 'acp-agents') - - try { - fs.mkdirSync(acpTempDir, { recursive: true }) - logger.info('[ACP Init] ACP temp directory:', acpTempDir) - } catch (error) { - console.error('[ACP Init] Failed to create ACP temp directory:', error) - // Fallback to process.cwd() if directory creation fails - return process.cwd() - } - - return acpTempDir - } - - /** - * Check if an external dependency is available - */ - private async checkExternalDependency(dep: ExternalDependency): Promise { - const platform = process.platform - - // Check if dependency supports current platform - if (dep.platform && !dep.platform.includes(platform)) { - logger.info(`[ACP Init] Dependency ${dep.name} not required on platform ${platform}`) - return true // Not required on this platform, consider it available - } - - // Method 1: Check via command - if (dep.checkCommand) { - try { - const { stdout } = await execAsync(dep.checkCommand, { timeout: 5000 }) - if (stdout && stdout.trim().length > 0) { - logger.info(`[ACP Init] Dependency ${dep.name} found via command: ${dep.checkCommand}`) - return true - } - } catch { - logger.info(`[ACP Init] Dependency ${dep.name} not found via command: ${dep.checkCommand}`) - } - } - - // Method 2: Check via paths - if (dep.checkPaths && dep.checkPaths.length > 0) { - for (const checkPath of dep.checkPaths) { - try { - if (fs.existsSync(checkPath)) { - logger.info(`[ACP Init] Dependency ${dep.name} found at path: ${checkPath}`) - return true - } - } catch { - // Continue checking other paths - } - } - } - - // Method 3: Use system tools to find command - if (dep.checkCommand) { - try { - const commandName = dep.checkCommand.split(' ')[0] - let findCommand: string - - if (platform === 'win32') { - findCommand = `where.exe ${commandName}` - } else { - findCommand = `which ${commandName}` - } - - const { stdout } = await execAsync(findCommand, { timeout: 5000 }) - if (stdout && stdout.trim().length > 0) { - logger.info(`[ACP Init] Dependency ${dep.name} found via system tool: ${findCommand}`) - return true - } - } catch { - // Command not found - } - } - - logger.info(`[ACP Init] Dependency ${dep.name} not found`) - return false - } - - /** - * Check required dependencies for an agent - */ - private async checkRequiredDependencies(agentId: string): Promise { - const platform = process.platform - const missingDeps: ExternalDependency[] = [] - - // Find dependencies required for this agent - const requiredDeps = EXTERNAL_DEPENDENCIES.filter( - (dep) => dep.requiredFor && dep.requiredFor.includes(agentId) - ) - - logger.info(`[ACP Init] Checking dependencies for agent ${agentId}:`, { - totalDeps: requiredDeps.length, - platform - }) - - // Check each dependency - for (const dep of requiredDeps) { - const isAvailable = await this.checkExternalDependency(dep) - if (!isAvailable) { - missingDeps.push(dep) - logger.info(`[ACP Init] Missing dependency: ${dep.name}`) - } - } - - return missingDeps - } - - /** - * Initialize a builtin ACP agent with terminal output streaming - */ - async initializeBuiltinAgent( - agentId: AcpBuiltinAgentId, - profile: AcpAgentProfile, - useBuiltinRuntime: boolean, - npmRegistry: string | null, - uvRegistry: string | null, - webContents?: WebContents - ): Promise { - logger.info('[ACP Init] Initializing builtin agent:', { - agentId, - useBuiltinRuntime, - npmRegistry, - uvRegistry, - hasWebContents: !!webContents, - profileName: profile.name - }) - - // Check external dependencies before initialization - const missingDeps = await this.checkRequiredDependencies(agentId) - if (missingDeps.length > 0) { - logger.info('[ACP Init] Missing dependencies detected, blocking initialization:', { - agentId, - missingCount: missingDeps.length - }) - if (webContents && !webContents.isDestroyed()) { - webContents.send( - DEEPCHAT_EVENT_CHANNEL, - createDeepchatEventEnvelope('acpTerminal.externalDependenciesRequired', { - agentId, - missingDeps, - version: Date.now() - }) - ) - } - // Stop initialization - user must install dependencies first - return null - } - - const initConfig = BUILTIN_INIT_COMMANDS[agentId] - if (!initConfig) { - console.error('[ACP Init] Unknown builtin agent:', agentId) - throw new Error(`Unknown builtin agent: ${agentId}`) - } - - logger.info('[ACP Init] Agent config:', { - description: initConfig.description, - commands: initConfig.commands - }) - - const envVars = await this.buildEnvironmentVariables( - profile, - useBuiltinRuntime, - npmRegistry, - uvRegistry - ) - - const commands = initConfig.commands - logger.info('[ACP Init] Starting interactive session with commands:', commands) - return this.startInteractiveSession(commands, envVars, webContents) - } - - /** - * Initialize a custom ACP agent with terminal output streaming - */ - async initializeCustomAgent( - agent: AcpAgentConfig, - useBuiltinRuntime: boolean, - npmRegistry: string | null, - uvRegistry: string | null, - webContents?: WebContents - ): Promise { - logger.info('[ACP Init] Initializing custom agent:', { - name: agent.name, - command: agent.command, - args: agent.args, - useBuiltinRuntime, - npmRegistry, - uvRegistry, - hasWebContents: !!webContents - }) - - const envVars = await this.buildEnvironmentVariables( - agent, - useBuiltinRuntime, - npmRegistry, - uvRegistry - ) - - // For custom agents, use the configured command - const command = agent.command - const args = agent.args || [] - const fullCommandStr = [command, ...args].join(' ') - - logger.info('[ACP Init] Starting interactive session with custom command:', fullCommandStr) - return this.startInteractiveSession([fullCommandStr], envVars, webContents) - } - - writeToTerminal(data: string) { - if (this.activeShell) { - try { - logger.info('[ACP Init] Writing to terminal:', { - dataLength: data.length, - dataPreview: data.substring(0, 50) - }) - this.activeShell.write(data) - } catch (error) { - console.warn('[ACP Init] Cannot write to terminal:', error) - } - } else { - console.warn('[ACP Init] Cannot write to terminal - shell not available') - } - } - - killTerminal() { - if (this.activeShell) { - logger.info('[ACP Init] Killing active shell process:', { - pid: this.activeShell.pid - }) - try { - this.activeShell.kill() - } catch (error) { - console.warn('[ACP Init] Error killing shell:', error) - } - this.activeShell = null - logger.info('[ACP Init] Shell process killed') - } else { - logger.info('[ACP Init] No active shell to kill') - } - } - - /** - * Start an interactive shell session - */ - private startInteractiveSession( - initCommands: string[], - envVars: Record, - webContents?: WebContents - ): IPty | null { - logger.info('[ACP Init] Starting interactive session:', { - commands: initCommands, - envVarCount: Object.keys(envVars).length, - hasWebContents: !!webContents - }) - - if (!webContents || webContents.isDestroyed()) { - console.error('[ACP Init] Cannot start session - webContents invalid or destroyed') - return null - } - - // Kill existing shell if any - this.killTerminal() - - // Get temporary directory for ACP agent shell session - const workDir = this.getAcpTempDir() - - const platform = process.platform - let shell: string - let shellArgs: string[] = [] - - if (platform === 'win32') { - shell = 'powershell.exe' - shellArgs = ['-NoLogo', '-ExecutionPolicy', 'Bypass'] - } else { - // Use user's default shell or bash/zsh - shell = process.env.SHELL || '/bin/bash' - // Force interactive mode for bash/zsh to get prompt and aliases - if (shell.endsWith('bash') || shell.endsWith('zsh')) { - shellArgs = ['-i'] - } - } - - logger.info('[ACP Init] Spawning shell with PTY:', { - platform, - shell, - shellArgs, - cwd: workDir - }) - - // Spawn PTY process - const pty = spawn(shell, shellArgs, { - name: 'xterm-color', - cols: 80, - rows: 24, - cwd: workDir, - env: { ...process.env, ...envVars } as Record - }) - - logger.info('[ACP Init] PTY process spawned:', { - pid: pty.pid - }) - - this.activeShell = pty - - // Track shell readiness for command injection - let shellReady = false - let outputBuffer = '' - let commandInjected = false - const maxWaitTime = 3000 // Maximum wait time for shell ready (3 seconds) - const startTime = Date.now() - - // Handle PTY output (PTY combines stdout and stderr into a single stream) - pty.onData((data: string) => { - outputBuffer += data - - logger.info('[ACP Init] PTY data:', { - length: data.length, - preview: data.substring(0, 100).replace(/\n/g, '\\n'), - shellReady, - commandInjected - }) - - // Detect shell readiness by looking for prompt patterns or any meaningful output - if (!shellReady && outputBuffer.length > 0) { - // Check for common shell prompt patterns or any non-empty output - const hasPromptPattern = - /[$#>]\s*$/.test(outputBuffer) || outputBuffer.includes('\n') || outputBuffer.length > 10 - - if (hasPromptPattern || Date.now() - startTime > 500) { - shellReady = true - logger.info('[ACP Init] Shell detected as ready, output length:', outputBuffer.length) - } - } - - // Send output to renderer (PTY output is treated as stdout) - if (!webContents.isDestroyed()) { - webContents.send( - DEEPCHAT_EVENT_CHANNEL, - createDeepchatEventEnvelope('acpTerminal.output', { - type: 'stdout', - data, - version: Date.now() - }) - ) - } - - // Inject command once shell is ready - if (shellReady && !commandInjected && initCommands.length > 0) { - commandInjected = true - const separator = platform === 'win32' ? ';' : '&&' - const initCmd = initCommands.join(` ${separator} `) - - logger.info('[ACP Init] Injecting initialization command (shell ready):', { - command: initCmd, - outputBufferLength: outputBuffer.length - }) - - // Small delay to ensure shell is fully ready - setTimeout(() => { - try { - pty.write(initCmd + '\n') - logger.info('[ACP Init] Command written to PTY') - } catch (error) { - console.warn('[ACP Init] Error writing command to PTY:', error) - } - }, 100) - } - }) - - // Handle process exit - pty.onExit(({ exitCode, signal }) => { - logger.info('[ACP Init] Process exited:', { - pid: pty.pid, - code: exitCode, - signal, - commandInjected - }) - if (!webContents.isDestroyed()) { - webContents.send( - DEEPCHAT_EVENT_CHANNEL, - createDeepchatEventEnvelope('acpTerminal.exited', { - code: exitCode, - signal: signal || null, - version: Date.now() - }) - ) - } - if (this.activeShell === pty) { - this.activeShell = null - logger.info('[ACP Init] Active shell cleared') - } - }) - - // Delay sending start event to ensure renderer listeners are set up - // Also inject command if shell doesn't become ready within timeout - setTimeout(() => { - if (!webContents.isDestroyed()) { - logger.info('[ACP Init] Sending start event (delayed to ensure listeners ready)') - webContents.send( - DEEPCHAT_EVENT_CHANNEL, - createDeepchatEventEnvelope('acpTerminal.started', { - command: shell, - version: Date.now() - }) - ) - } - - // Fallback: inject command if shell hasn't become ready yet - if (!commandInjected && initCommands.length > 0 && Date.now() - startTime < maxWaitTime) { - logger.info('[ACP Init] Fallback: injecting command after delay (shell may be ready)') - commandInjected = true - const separator = platform === 'win32' ? ';' : '&&' - const initCmd = initCommands.join(` ${separator} `) - - setTimeout(() => { - try { - pty.write(initCmd + '\n') - logger.info('[ACP Init] Fallback command written to PTY') - } catch (error) { - console.warn('[ACP Init] Error writing fallback command to PTY:', error) - } - }, 200) - } - }, 500) // Delay to ensure renderer listeners are set up - - return pty - } - - /** - * Build environment variables for the terminal - */ - private async buildEnvironmentVariables( - profile: AcpAgentProfile | AcpAgentConfig, - useBuiltinRuntime: boolean, - npmRegistry: string | null, - uvRegistry: string | null - ): Promise> { - logger.info('[ACP Init] Building environment variables:', { - useBuiltinRuntime, - npmRegistry, - uvRegistry, - hasProfileEnv: !!(profile.env && Object.keys(profile.env).length > 0) - }) - - let env = mergeCommandEnvironment() - const systemEnvCount = Object.keys(env).length - logger.info('[ACP Init] Added system environment variables:', systemEnvCount) - - try { - const shellEnv = await getShellEnvironment() - env = mergeCommandEnvironment({ shellEnv }) - } catch (error) { - console.warn('[ACP Init] Failed to merge shell environment variables:', error) - } - - const prependPathSources: string[] = [] - - if (useBuiltinRuntime) { - const uvRuntimePath = this.runtimeHelper.getUvRuntimePath() - const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() - - if (uvRuntimePath) { - prependPathSources.push(uvRuntimePath) - logger.info('[ACP Init] Added UV runtime path:', uvRuntimePath) - } - - if (process.platform === 'win32') { - if (nodeRuntimePath) { - prependPathSources.push(nodeRuntimePath) - logger.info('[ACP Init] Added Node runtime path (Windows):', nodeRuntimePath) - } - } else if (nodeRuntimePath) { - const nodeBinPath = path.join(nodeRuntimePath, 'bin') - prependPathSources.push(nodeBinPath) - logger.info('[ACP Init] Added Node runtime path (Unix):', nodeBinPath) - } - - if (prependPathSources.length > 0) { - setPathEntriesOnEnv(env, [prependPathSources, getPathEntriesFromEnv(env)], { - includeDefaultPaths: false - }) - } else { - console.warn('[ACP Init] No runtime paths available to add to PATH') - } - } - - if (useBuiltinRuntime) { - if (npmRegistry && npmRegistry !== '') { - env.npm_config_registry = npmRegistry - env.NPM_CONFIG_REGISTRY = npmRegistry - logger.info('[ACP Init] Set NPM registry:', npmRegistry) - } - - if (uvRegistry && uvRegistry !== '') { - env.UV_DEFAULT_INDEX = uvRegistry - env.PIP_INDEX_URL = uvRegistry - logger.info('[ACP Init] Set UV registry:', uvRegistry) - } - - if (process.platform === 'win32' && this.runtimeHelper.isInstalledInSystemDirectory()) { - const userNpmPrefix = this.runtimeHelper.getUserNpmPrefix() - - if (userNpmPrefix) { - env.npm_config_prefix = userNpmPrefix - env.NPM_CONFIG_PREFIX = userNpmPrefix - logger.info( - '[ACP Init] Set NPM prefix to user directory (system install detected):', - userNpmPrefix - ) - - const userNpmBinPath = userNpmPrefix - setPathEntriesOnEnv(env, [userNpmBinPath, getPathEntriesFromEnv(env)], { - includeDefaultPaths: false - }) - - logger.info('[ACP Init] Added user npm bin directory to PATH:', userNpmBinPath) - } - } - } - - if (profile.env) { - const customEnvCount = Object.entries(profile.env).filter( - ([, value]) => value !== undefined && value !== '' - ).length - Object.entries(profile.env).forEach(([key, value]) => { - if (value !== undefined && value !== '' && !['PATH', 'Path', 'path'].includes(key)) { - env[key] = value - logger.info('[ACP Init] Added custom env var:', key) - } - }) - - const customPathEntries = getPathEntriesFromEnv(profile.env) - if (customPathEntries.length > 0) { - setPathEntriesOnEnv(env, [customPathEntries, getPathEntriesFromEnv(env)], { - includeDefaultPaths: false - }) - logger.info('[ACP Init] Merged custom PATH from profile:', { - customPath: profile.env.PATH || profile.env.Path || profile.env.path, - mergedPathLength: env[process.platform === 'win32' ? 'Path' : 'PATH']?.length || 0 - }) - } - - logger.info('[ACP Init] Added custom environment variables from profile:', customEnvCount) - } - - logger.info('[ACP Init] Environment variables built:', { - totalEnvVars: Object.keys(env).length, - pathLength: env[process.platform === 'win32' ? 'Path' : 'PATH']?.length || 0 - }) - - return env - } -} - -// Export helper functions -let initHelperInstance: AcpInitHelper | null = null - -function getInitHelper(): AcpInitHelper { - if (!initHelperInstance) { - initHelperInstance = new AcpInitHelper() - } - return initHelperInstance -} - -export async function initializeBuiltinAgent( - agentId: AcpBuiltinAgentId, - profile: AcpAgentProfile, - useBuiltinRuntime: boolean, - npmRegistry: string | null, - uvRegistry: string | null, - webContents?: WebContents -): Promise { - return getInitHelper().initializeBuiltinAgent( - agentId, - profile, - useBuiltinRuntime, - npmRegistry, - uvRegistry, - webContents - ) -} - -export async function initializeCustomAgent( - agent: AcpAgentConfig, - useBuiltinRuntime: boolean, - npmRegistry: string | null, - uvRegistry: string | null, - webContents?: WebContents -): Promise { - return getInitHelper().initializeCustomAgent( - agent, - useBuiltinRuntime, - npmRegistry, - uvRegistry, - webContents - ) -} - -export function writeToTerminal(data: string): void { - getInitHelper().writeToTerminal(data) -} - -export function killTerminal(): void { - getInitHelper().killTerminal() -} diff --git a/src/main/agent/acp/routes.ts b/src/main/agent/acp/routes.ts index 599b97b017..6c4fd8320c 100644 --- a/src/main/agent/acp/routes.ts +++ b/src/main/agent/acp/routes.ts @@ -1,21 +1,63 @@ import { - acpTerminalInputRoute, - acpTerminalKillRoute, - type DeepchatRouteName + acpAuthCancelRoute, + acpAuthInputRoute, + acpAuthInspectRoute, + acpAuthStartRoute, + acpAuthStatusRoute } from '@shared/contracts/routes' -import { killTerminal, writeToTerminal } from './launch/acpInitHelper' +import { createRouteMap, requireRendererCaller } from '@/routes/routeRegistry' +import type { AcpAuthService } from './auth/acpAuthService' -export function createAcpRoutes() { - const routes = new Map Promise>() - routes.set(acpTerminalInputRoute.name, async (rawInput) => { - const input = acpTerminalInputRoute.input.parse(rawInput) - writeToTerminal(input.data) - return acpTerminalInputRoute.output.parse({ sent: true }) - }) - routes.set(acpTerminalKillRoute.name, async (rawInput) => { - acpTerminalKillRoute.input.parse(rawInput) - killTerminal() - return acpTerminalKillRoute.output.parse({ killed: true }) - }) - return routes +export function createAcpRoutes(dependencies: { auth: AcpAuthService }) { + return createRouteMap([ + [ + acpAuthInspectRoute.name, + async (rawInput, context) => { + requireRendererCaller(context) + const input = acpAuthInspectRoute.input.parse(rawInput) + return acpAuthInspectRoute.output.parse({ + challenge: await dependencies.auth.inspect(input.agentId, input.workdir) + }) + } + ], + [ + acpAuthStartRoute.name, + async (rawInput, context) => { + const input = acpAuthStartRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) + return acpAuthStartRoute.output.parse( + await dependencies.auth.start(input.challengeId, input.methodId, caller.webContentsId) + ) + } + ], + [ + acpAuthInputRoute.name, + async (rawInput, context) => { + const input = acpAuthInputRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) + dependencies.auth.write(input.runId, caller.webContentsId, input.data) + return acpAuthInputRoute.output.parse({ sent: true }) + } + ], + [ + acpAuthCancelRoute.name, + async (rawInput, context) => { + const input = acpAuthCancelRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) + return acpAuthCancelRoute.output.parse({ + cancelled: dependencies.auth.cancel(input.runId, caller.webContentsId) + }) + } + ], + [ + acpAuthStatusRoute.name, + async (rawInput, context) => { + const input = acpAuthStatusRoute.input.parse(rawInput) + const caller = requireRendererCaller(context) + return acpAuthStatusRoute.output.parse( + dependencies.auth.getStatus(input.challengeId, caller.webContentsId) + ) + } + ] + ]) } diff --git a/src/main/agent/acp/runtime/acpAuthentication.ts b/src/main/agent/acp/runtime/acpAuthentication.ts new file mode 100644 index 0000000000..2ea7a6260e --- /dev/null +++ b/src/main/agent/acp/runtime/acpAuthentication.ts @@ -0,0 +1,38 @@ +import type { AcpAuthChallenge, AcpAuthMethodView } from '@shared/types/acp' +import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js' + +export const ACP_AUTH_REQUIRED_CODE = -32000 + +export function isAcpAuthRequiredRpcError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + error.code === ACP_AUTH_REQUIRED_CODE + ) +} + +export function normalizeAcpAuthMethod(method: schema.AuthMethod): AcpAuthMethodView { + const type = 'type' in method ? method.type : 'agent' + return { + id: method.id, + name: method.name, + ...(method.description ? { description: method.description } : {}), + type: type === 'agent' || type === 'terminal' ? type : 'unsupported' + } +} + +export class AcpAuthenticationRequiredError extends Error { + readonly code = ACP_AUTH_REQUIRED_CODE + + constructor(readonly challenge: AcpAuthChallenge) { + super(`Authentication required for ACP agent ${challenge.agentId}`) + this.name = 'AcpAuthenticationRequiredError' + } +} + +export function isAcpAuthenticationRequiredError( + error: unknown +): error is AcpAuthenticationRequiredError { + return error instanceof AcpAuthenticationRequiredError +} diff --git a/src/main/agent/acp/runtime/acpProcessManager.ts b/src/main/agent/acp/runtime/acpProcessManager.ts index 76db127c43..12379e9f32 100644 --- a/src/main/agent/acp/runtime/acpProcessManager.ts +++ b/src/main/agent/acp/runtime/acpProcessManager.ts @@ -1,6 +1,7 @@ import spawn from 'cross-spawn' import type { ChildProcessWithoutNullStreams } from 'child_process' import { Readable, Writable } from 'node:stream' +import { randomUUID } from 'node:crypto' import { app } from 'electron' import * as fs from 'fs' import * as path from 'path' @@ -14,6 +15,8 @@ import type { Stream } from '@agentclientprotocol/sdk/dist/stream.js' import type { AcpAgentConfig, AcpAgentState, + AcpAuthChallenge, + AcpAuthChallengeOrigin, AcpConfigState, AcpDebugEventEntry, AcpResolvedLaunchSpec @@ -43,6 +46,14 @@ import { updateAcpConfigStateValue } from './acpConfigState' import { AcpDebugLog } from './acpDebugLog' +import { normalizeAcpAuthMethod } from './acpAuthentication' + +export interface AcpMaterializedLaunch { + command: string + args: string[] + env: Record + cwd: string +} export interface AcpProcessHandle extends AgentProcessHandle { child: ChildProcessWithoutNullStreams @@ -69,6 +80,7 @@ export interface AcpProcessHandle extends AgentProcessHandle { supportsSessionClose?: boolean supportsSessionFork?: boolean launchSignature: string + materializedLaunch: AcpMaterializedLaunch } interface AcpProcessManagerOptions { @@ -78,6 +90,19 @@ interface AcpProcessManagerOptions { getAgentState?: (agentId: string) => Promise getNpmRegistry?: () => Promise getUvRegistry?: () => Promise + terminalAuthAvailable?: boolean +} + +interface StoredAuthChallenge { + public: AcpAuthChallenge + agent: AcpAgentConfig + handle: AcpProcessHandle + launchSignature: string + methods: schema.AuthMethod[] + materializedLaunch: AcpMaterializedLaunch + createdAt: number + consumed: boolean + active: boolean } export type SessionNotificationHandler = (notification: schema.SessionNotification) => void @@ -184,7 +209,10 @@ export const parseLoadSessionCapability = (initializeResult: unknown): boolean | return Boolean(loadSession) } -const createLaunchSignature = (launchSpec: AcpResolvedLaunchSpec): string => +const createLaunchSignature = ( + launchSpec: AcpResolvedLaunchSpec, + userEnvOverride?: Record +): string => JSON.stringify({ command: launchSpec.command, args: launchSpec.args ?? [], @@ -192,9 +220,12 @@ const createLaunchSignature = (launchSpec: AcpResolvedLaunchSpec): string => cwd: launchSpec.cwd ?? null, distributionType: launchSpec.distributionType, version: launchSpec.version ?? null, - installDir: launchSpec.installDir ?? null + installDir: launchSpec.installDir ?? null, + userEnvOverride: userEnvOverride ?? {} }) +const AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000 + export class AcpProcessManager implements AgentProcessManager { private readonly publishEvent: DeepChatEventPublisher private readonly providerId: string @@ -205,6 +236,7 @@ export class AcpProcessManager implements AgentProcessManager Promise private readonly getNpmRegistry?: () => Promise private readonly getUvRegistry?: () => Promise + private readonly terminalAuthAvailable: boolean private readonly handles = new Map() private readonly boundHandles = new Map() private readonly pendingHandles = new Map>() @@ -233,6 +265,8 @@ export class AcpProcessManager implements AgentProcessManager>() private readonly protocolRequestsFromAgent = new Map>() + private readonly authChallenges = new Map() + private readonly activeAuthScopes = new Map() private readonly initializingChildren = new Set() private readonly terminatedChildren = new WeakSet() private readonly disposedHandles = new WeakSet() @@ -246,6 +280,7 @@ export class AcpProcessManager implements AgentProcessManager this.isHandleAlive(handle) ).length @@ -474,7 +511,13 @@ export class AcpProcessManager implements AgentProcessManager { + const normalized = normalizeAcpAuthMethod(method) + return normalized.type === 'terminal' && !this.terminalAuthAvailable + ? { ...normalized, type: 'unsupported' as const } + : normalized + }), + origin: input.origin, + ...(input.sessionId ? { sessionId: input.sessionId } : {}) + } + this.authChallenges.set(challenge.id, { + public: challenge, + agent: handle.agent, + handle, + launchSignature: handle.launchSignature, + methods: [...(handle.authMethods ?? [])], + materializedLaunch: { + command: handle.materializedLaunch.command, + args: [...handle.materializedLaunch.args], + env: { ...handle.materializedLaunch.env }, + cwd: handle.materializedLaunch.cwd + }, + createdAt: Date.now(), + consumed: false, + active: false + }) + return challenge + } + + getAuthChallenge(challengeId: string): AcpAuthChallenge { + return this.requireStoredAuthChallenge(challengeId).public + } + + async inspectAuthentication( + agent: AcpAgentConfig, + workdir?: string + ): Promise { + const handle = await this.getConnection(agent, workdir) + return this.createAuthChallenge(handle, { origin: 'settings_probe' }) + } + + async authenticateAgent(challengeId: string, methodId: string): Promise { + const challenge = await this.claimAuthChallenge(challengeId, methodId, 'agent') + try { + await challenge.handle.connection.authenticate({ methodId }) + challenge.consumed = true + } finally { + this.releaseAuthChallenge(challenge) + } + } + + async prepareTerminalAuthentication( + challengeId: string, + methodId: string + ): Promise<{ + challenge: AcpAuthChallenge + launch: AcpMaterializedLaunch + }> { + const challenge = await this.claimAuthChallenge(challengeId, methodId, 'terminal') + const method = challenge.methods.find((candidate) => candidate.id === methodId) + if (!method || !('type' in method) || method.type !== 'terminal') { + this.releaseAuthChallenge(challenge) + throw new Error('Selected ACP authentication method is not a terminal method') + } + return { + challenge: challenge.public, + launch: { + command: challenge.materializedLaunch.command, + args: [...challenge.materializedLaunch.args, ...(method.args ?? [])], + env: { ...challenge.materializedLaunch.env, ...method.env }, + cwd: challenge.materializedLaunch.cwd + } + } + } + + async completeTerminalAuthentication(challengeId: string): Promise { + const challenge = this.requireStoredAuthChallenge(challengeId) + if (!challenge.active) { + throw new Error('ACP authentication challenge is not active') + } + try { + await this.disposeHandle(challenge.handle) + await this.getConnection(challenge.agent, challenge.public.workdir) + challenge.consumed = true + } finally { + this.releaseAuthChallenge(challenge) + } + } + + abandonAuthentication(challengeId: string): void { + const challenge = this.authChallenges.get(challengeId) + if (challenge && !challenge.consumed) this.releaseAuthChallenge(challenge) + } + + private async claimAuthChallenge( + challengeId: string, + methodId: string, + expectedType: 'agent' | 'terminal' + ): Promise { + const challenge = this.requireStoredAuthChallenge(challengeId) + if (challenge.active) throw new Error('ACP authentication is already running') + if (!this.isHandleAlive(challenge.handle)) { + throw new Error('ACP authentication challenge is stale') + } + + const [launchSpec, agentState] = await Promise.all([ + this.resolveLaunchSpec(challenge.agent.id, challenge.public.workdir), + this.getAgentState?.(challenge.agent.id) + ]) + const currentSignature = createLaunchSignature(launchSpec, agentState?.envOverride) + if (currentSignature !== challenge.launchSignature) { + throw new Error('ACP authentication challenge is stale') + } + + const scopeKey = this.getAuthScopeKey(challenge.public) + const activeChallengeId = this.activeAuthScopes.get(scopeKey) + if (activeChallengeId && activeChallengeId !== challenge.public.id) { + throw new Error('ACP authentication is already running for this agent and workdir') + } + + const method = challenge.methods.find((candidate) => candidate.id === methodId) + if (!method) throw new Error('ACP authentication method is no longer available') + const methodType = 'type' in method ? method.type : 'agent' + if (methodType !== expectedType) { + throw new Error(`ACP authentication method does not support ${expectedType} authentication`) + } + challenge.active = true + this.activeAuthScopes.set(scopeKey, challenge.public.id) + return challenge + } + + private getAuthScopeKey(challenge: AcpAuthChallenge): string { + return JSON.stringify([challenge.agentId, challenge.workdir]) + } + + private releaseAuthChallenge(challenge: StoredAuthChallenge): void { + challenge.active = false + const scopeKey = this.getAuthScopeKey(challenge.public) + if (this.activeAuthScopes.get(scopeKey) === challenge.public.id) { + this.activeAuthScopes.delete(scopeKey) + } + } + + private requireStoredAuthChallenge(challengeId: string): StoredAuthChallenge { + this.pruneAuthChallenges() + const challenge = this.authChallenges.get(challengeId) + if (!challenge || challenge.consumed) { + throw new Error('ACP authentication challenge is unavailable or expired') + } + return challenge + } + + private pruneAuthChallenges(now = Date.now()): void { + for (const [challengeId, challenge] of this.authChallenges) { + if (!challenge.active && now - challenge.createdAt > AUTH_CHALLENGE_TTL_MS) { + this.authChallenges.delete(challengeId) + } + } + } + async release(agentId: string): Promise { const targets = this.getHandlesByAgent(agentId) if (!targets.length) return @@ -640,6 +855,8 @@ export class AcpProcessManager implements AgentProcessManager { await Promise.allSettled(handleCleanup) @@ -835,11 +1052,18 @@ export class AcpProcessManager implements AgentProcessManager { this.assertAcceptingProcesses() try { - const handle = await this.spawnProcessOnce(agent, workdir, launchSpec, launchSignature) + const handle = await this.spawnProcessOnce( + agent, + workdir, + launchSpec, + launchSignature, + agentState + ) if (this.shuttingDown) { await this.disposeHandle(handle) this.assertAcceptingProcesses() @@ -856,7 +1080,13 @@ export class AcpProcessManager implements AgentProcessManager { this.assertAcceptingProcesses() - const child = await this.spawnAgentProcess(agent, workdir, launchSpec) + const materializedLaunch = await this.materializeAgentLaunch( + agent, + workdir, + launchSpec, + agentState + ) + const child = this.spawnAgentProcess(agent, materializedLaunch) if (this.shuttingDown) { this.killChild(child, 'late spawn') this.assertAcceptingProcesses() @@ -888,7 +1125,8 @@ export class AcpProcessManager implements AgentProcessManager { const stderrChunks: string[] = [] const stream = this.createAgentStream(agent.id, child) @@ -981,7 +1220,8 @@ export class AcpProcessManager implements AgentProcessManager { + launchSpec: AcpResolvedLaunchSpec, + agentState: AcpAgentState | null | undefined + ): Promise { this.assertAcceptingProcesses() - // Initialize runtime paths if not already done this.runtimeHelper.initializeRuntimes() - const agentState = await this.getAgentState?.(agent.id) - this.assertAcceptingProcesses() // Validate command if (!launchSpec.command || launchSpec.command.trim().length === 0) { @@ -1382,7 +1621,7 @@ export class AcpProcessManager implements AgentProcessManager mcpService.getUvRegistry() } }) + const acpAuthService = new AcpAuthService({ + owner: acpRuntimeOwner, + agentSettings, + sendToRenderer: (webContentsId, name, payload) => { + const target = electronWebContents.fromId(webContentsId) + if (!target || target.isDestroyed()) return + target.send(DEEPCHAT_EVENT_CHANNEL, createDeepchatEventEnvelope(name, payload)) + }, + onRendererDestroyed: (webContentsId, callback) => { + const target = electronWebContents.fromId(webContentsId) + if (!target || target.isDestroyed()) { + queueMicrotask(callback) + return () => {} + } + target.once('destroyed', callback) + return () => target.removeListener('destroyed', callback) + } + }) providerRuntime = new ProviderRuntime( providerSettings, desktopSettings, @@ -2879,7 +2897,7 @@ export async function createMainProcessControl(dependencies: { }) } }) - const acpRoutes = createAcpRoutes() + const acpRoutes = createAcpRoutes({ auth: acpAuthService }) const deviceRoutes = createDeviceRoutes({ device: deviceService, restartApplication, @@ -3261,7 +3279,7 @@ export async function createMainProcessControl(dependencies: { appLifecycleState = 'stopping' windowPresenter.setApplicationQuitting(true) startupWorkloadCoordinator.cancelTarget('main') - await runDestroyStep('acpInitTerminal.kill', () => killTerminal()) + await runDestroyStep('acpAuth.shutdown', () => acpAuthService.shutdown()) try { await destroy() } finally { diff --git a/src/main/session/contracts.ts b/src/main/session/contracts.ts index 37de800c42..a219835cb9 100644 --- a/src/main/session/contracts.ts +++ b/src/main/session/contracts.ts @@ -49,6 +49,7 @@ import type { import type { OrchestrationPolicy } from '@shared/orchestration/policy' import type { LiveDelegationSubagentContext } from '@shared/orchestration/liveDelegation' import type { AcpConfigState } from '@shared/types/acp' +import type { AcpAuthChallenge } from '@shared/types/acp' import type { AcpAsLlmProviderSessionControlPort } from '@/provider/ports' import type { CommandShellProfile } from '@shared/commandShell' import type { ToolPermissionLeaseCapability } from '@shared/types/tool' @@ -597,7 +598,7 @@ export interface SessionLifecyclePort { agentId: string projectDir: string permissionMode?: PermissionMode - }): Promise + }): Promise forkSession( sourceSessionId: string, targetMessageId: string, @@ -606,6 +607,10 @@ export interface SessionLifecyclePort { deleteSession(sessionId: string): Promise } +export type EnsureAcpDraftResult = + | { status: 'ready'; session: SessionWithState } + | { status: 'auth_required'; session: SessionWithState; challenge: AcpAuthChallenge } + export interface SessionAgentAssignmentPort { linkSubagentTape(input: SubagentTapeLinkInput): Promise getAgentTransferImpact(agentId: string): Promise diff --git a/src/main/session/lifecycle.ts b/src/main/session/lifecycle.ts index 213e18bd34..fb6de816bb 100644 --- a/src/main/session/lifecycle.ts +++ b/src/main/session/lifecycle.ts @@ -28,6 +28,8 @@ import type { ResolvedSessionAssignment, ResolvedSubagentAssignment } from './contracts' +import { isAcpAuthenticationRequiredError } from '@/agent/acp/runtime/acpAuthentication' +import type { EnsureAcpDraftResult } from './contracts' import type { AgentLifecycleGatePort } from '@/agent/lifecycleGate' import { LiveDelegationSubagentContextSchema } from '@shared/orchestration/liveDelegation' import { @@ -401,7 +403,7 @@ export class SessionLifecycle implements SessionLifecyclePort { agentId: string projectDir: string permissionMode?: PermissionMode - }): Promise { + }): Promise { const agentId = input.agentId?.trim() if (!agentId) throw new Error('ACP draft session requires an agentId.') @@ -444,7 +446,25 @@ export class SessionLifecycle implements SessionLifecyclePort { }) } - await this.dependencies.workdir.prepareDirectAcpSession(record.id) + try { + await this.dependencies.workdir.prepareDirectAcpSession(record.id) + } catch (error) { + if (!isAcpAuthenticationRequiredError(error)) throw error + this.dependencies.projection.notify({ + sessionIds: [record.id], + reason: createdDraftSession ? 'created' : 'updated' + }) + return { + status: 'auth_required', + session: { + ...record, + status: 'error', + providerId: 'acp', + modelId: canonicalAgentId + }, + challenge: error.challenge + } + } this.dependencies.projection.notify({ sessionIds: [record.id], reason: createdDraftSession ? 'created' : 'updated' @@ -453,10 +473,13 @@ export class SessionLifecycle implements SessionLifecyclePort { .resolveSession(toAppSessionId(record.id)) .snapshot() return { - ...record, - status: state?.status ?? 'idle', - providerId: state?.providerId ?? 'acp', - modelId: state?.modelId ?? canonicalAgentId + status: 'ready', + session: { + ...record, + status: state?.status ?? 'idle', + providerId: state?.providerId ?? 'acp', + modelId: state?.modelId ?? canonicalAgentId + } } } diff --git a/src/main/session/routes.ts b/src/main/session/routes.ts index 85a00789b4..e91ffc5210 100644 --- a/src/main/session/routes.ts +++ b/src/main/session/routes.ts @@ -256,9 +256,9 @@ export function createSessionRoutes(deps: { sessionsEnsureAcpDraftRoute.name, async (rawInput) => { const input = sessionsEnsureAcpDraftRoute.input.parse(rawInput) - return sessionsEnsureAcpDraftRoute.output.parse({ - session: await deps.lifecycle.ensureAcpDraftSession(input) - }) + return sessionsEnsureAcpDraftRoute.output.parse( + await deps.lifecycle.ensureAcpDraftSession(input) + ) } ], [ diff --git a/src/renderer/api/AcpAuthClient.ts b/src/renderer/api/AcpAuthClient.ts new file mode 100644 index 0000000000..e16687fb87 --- /dev/null +++ b/src/renderer/api/AcpAuthClient.ts @@ -0,0 +1,41 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { + acpAuthOutputEvent, + acpAuthStateChangedEvent, + type DeepchatEventPayload +} from '@shared/contracts/events' +import { + acpAuthCancelRoute, + acpAuthInputRoute, + acpAuthInspectRoute, + acpAuthStartRoute, + acpAuthStatusRoute +} from '@shared/contracts/routes' +import { getDeepchatBridge } from './core' + +export function createAcpAuthClient(bridge: DeepchatBridge = getDeepchatBridge()) { + const inspect = (agentId: string, workdir?: string) => + bridge.invoke(acpAuthInspectRoute.name, { agentId, workdir }) + + const start = (challengeId: string, methodId: string) => + bridge.invoke(acpAuthStartRoute.name, { challengeId, methodId }) + + const sendInput = (runId: string, data: string) => + bridge.invoke(acpAuthInputRoute.name, { runId, data }) + + const cancel = (runId: string) => bridge.invoke(acpAuthCancelRoute.name, { runId }) + + const getStatus = (challengeId: string) => bridge.invoke(acpAuthStatusRoute.name, { challengeId }) + + const onOutput = ( + listener: (payload: DeepchatEventPayload) => void + ) => bridge.on(acpAuthOutputEvent.name, listener) + + const onStateChanged = ( + listener: (payload: DeepchatEventPayload) => void + ) => bridge.on(acpAuthStateChangedEvent.name, listener) + + return { inspect, start, sendInput, cancel, getStatus, onOutput, onStateChanged } +} + +export type AcpAuthClient = ReturnType diff --git a/src/renderer/api/AcpTerminalClient.ts b/src/renderer/api/AcpTerminalClient.ts deleted file mode 100644 index ba9b7f0927..0000000000 --- a/src/renderer/api/AcpTerminalClient.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { DeepchatBridge } from '@shared/contracts/bridge' -import { - acpTerminalErrorEvent, - acpTerminalExitedEvent, - acpTerminalExternalDependenciesRequiredEvent, - acpTerminalOutputEvent, - acpTerminalStartedEvent, - type DeepchatEventPayload -} from '@shared/contracts/events' -import { acpTerminalInputRoute, acpTerminalKillRoute } from '@shared/contracts/routes' -import { getDeepchatBridge } from './core' - -export function createAcpTerminalClient(bridge: DeepchatBridge = getDeepchatBridge()) { - async function sendInput(data: string) { - const result = await bridge.invoke(acpTerminalInputRoute.name, { data }) - return result.sent - } - - async function kill() { - const result = await bridge.invoke(acpTerminalKillRoute.name, {}) - return result.killed - } - - function onStarted( - listener: (payload: DeepchatEventPayload) => void - ) { - return bridge.on(acpTerminalStartedEvent.name, listener) - } - - function onOutput( - listener: (payload: DeepchatEventPayload) => void - ) { - return bridge.on(acpTerminalOutputEvent.name, listener) - } - - function onExited( - listener: (payload: DeepchatEventPayload) => void - ) { - return bridge.on(acpTerminalExitedEvent.name, listener) - } - - function onError( - listener: (payload: DeepchatEventPayload) => void - ) { - return bridge.on(acpTerminalErrorEvent.name, listener) - } - - function onExternalDependenciesRequired( - listener: ( - payload: DeepchatEventPayload - ) => void - ) { - return bridge.on(acpTerminalExternalDependenciesRequiredEvent.name, listener) - } - - return { - sendInput, - kill, - onStarted, - onOutput, - onExited, - onError, - onExternalDependenciesRequired - } -} - -export type AcpTerminalClient = ReturnType diff --git a/src/renderer/api/SessionClient.ts b/src/renderer/api/SessionClient.ts index f170dbbeaa..47a7b8abe6 100644 --- a/src/renderer/api/SessionClient.ts +++ b/src/renderer/api/SessionClient.ts @@ -172,7 +172,7 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() permissionMode?: PermissionMode }) { const result = await bridge.invoke(sessionsEnsureAcpDraftRoute.name, input) - return result.session + return result } async function listPendingInputs(sessionId: string) { diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 4d032a3744..86565196e0 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -1,4 +1,4 @@ -export * from './AcpTerminalClient' +export * from './AcpAuthClient' export * from './AppRuntimeClient' export * from './BrowserClient' export * from './ConfigClient' diff --git a/src/renderer/settings/components/AcpSettings.vue b/src/renderer/settings/components/AcpSettings.vue index 6c8f2bbae3..cc5763ef1e 100644 --- a/src/renderer/settings/components/AcpSettings.vue +++ b/src/renderer/settings/components/AcpSettings.vue @@ -211,6 +211,15 @@ > {{ t('settings.acp.registryRepair') }} + + + {{ t('settings.acp.auth.checkSignIn') }} + {{ t('common.delete') }} + + + {{ t('settings.acp.auth.checkSignIn') }} + + + import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' -import type { AcpManualAgent } from '@shared/types/acp' +import type { AcpAuthChallenge, AcpManualAgent } from '@shared/types/acp' import type { AcpRegistryAgent } from '@shared/types/acp' import type { AgentTransferImpact } from '@shared/types/agent-interface' import { useI18n } from 'vue-i18n' @@ -639,6 +663,7 @@ import { DialogTitle } from '@shadcn/components/ui/dialog' import AcpDebugDialog from './AcpDebugDialog.vue' +import AcpAuthDialog from '@/components/acp/AcpAuthDialog.vue' import AgentTransferDialog from '@/components/agent/AgentTransferDialog.vue' import AgentMcpSelector from '@/components/mcp-config/AgentMcpSelector.vue' import AcpAgentIcon from '@/components/icons/AcpAgentIcon.vue' @@ -648,9 +673,11 @@ import { DcSubmitButton, useDcFormSubmit } from '@dc-ui/components/form' import { DcFormActions } from '@dc-ui/components/form-actions' import type { DcFormSubmitStatus } from '@dc-ui/components/form' import { settingsLeaveGuard, type SettingsLeaveRisk } from '../services/settingsLeaveGuard' +import { createAcpAuthClient } from '@api/AcpAuthClient' const { t } = useI18n() const configClient = createConfigClient() +const acpAuthClient = createAcpAuthClient() type RegistryDialogFilter = 'all' | 'installed' | 'not_installed' type PendingDeleteAgent = { @@ -740,6 +767,9 @@ const debugDialog = reactive({ agentId: '', agentName: '' }) +const authChallenge = ref(null) +const authDialogOpen = ref(false) +const authInspectingAgentId = ref(null) const manualDialog = reactive({ open: false, @@ -1194,6 +1224,26 @@ const openInspector = (agentId: string, agentName: string) => { debugDialog.open = true } +const openAuthentication = async (agentId: string) => { + if (authInspectingAgentId.value) return + authInspectingAgentId.value = agentId + try { + const result = await acpAuthClient.inspect(agentId) + authChallenge.value = result.challenge + authDialogOpen.value = true + } catch (error) { + console.error('[ACP] Failed to inspect authentication methods:', error) + notifyRenderer({ + kind: 'error', + code: 'settings.acp.auth.inspectFailed', + title: t('common.error.operationFailed'), + description: t('common.error.requestFailed') + }) + } finally { + authInspectingAgentId.value = null + } +} + const clearManualDialogError = () => { manualDialog.error = null } diff --git a/src/renderer/src/components/acp/AcpAuthDialog.vue b/src/renderer/src/components/acp/AcpAuthDialog.vue new file mode 100644 index 0000000000..fa90ebcf08 --- /dev/null +++ b/src/renderer/src/components/acp/AcpAuthDialog.vue @@ -0,0 +1,237 @@ + + + diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 3aaa2888a1..e9cbcc51b1 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -1761,6 +1761,25 @@ "cannotDeleteDesc": "Indbyggede agenter kræver mindst én konfiguration.", "noAgent": "Vælg en agent for at administrere dens profiler." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Initialiseringsterminal", "waiting": "Venter på start...", diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 6fe2b370c8..55d97280b8 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -2187,6 +2187,25 @@ "cannotDeleteDesc": "Integrierte Agent benötigen mindestens eine Konfiguration.", "noAgent": "Bitte Agent auswählen, der verwaltet werden soll." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Initialisierungsterminal", "waiting": "Warten auf Start der Initialisierung...", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index e9646c3a63..81c1637d0d 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -2222,6 +2222,25 @@ "cannotDeleteDesc": "Built-in agents require at least one configuration.", "noAgent": "Select an agent to manage its profiles." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Initialization Terminal", "waiting": "Waiting for initialization to start...", diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index 57d7d6c0fb..a247d72bf9 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -2187,6 +2187,25 @@ "cannotDeleteDesc": "El agents integrado requiere al menos una configuración.", "noAgent": "Seleccione un agent para administrar sus perfiles." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Terminal de inicialización", "waiting": "Esperando a que comience la inicialización...", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 5e9d9f9310..2ed2456038 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -1854,6 +1854,25 @@ "initializeFailed": "اولیه سازی انجام نشد", "initializeSuccess": "راه اندازی آغاز شد", "initializing": "در حال شروع...", + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "close": "بسته شدن", "closing": "بسته شدن...", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 71b5e5dfb2..0781df5d5e 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -1854,6 +1854,25 @@ "initializeFailed": "L'initialisation a échoué", "initializeSuccess": "L'initialisation a commencé", "initializing": "Initialisation...", + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "close": "fermeture", "closing": "Clôture...", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 6b89a4ce1d..40c16111cd 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -1828,6 +1828,25 @@ "cannotDeleteDesc": "סוכנים מובנים דורשים לפחות תצורה אחת.", "noAgent": "בחר סוכן כדי לנהל את הפרופילים שלו." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "טרמינל אתחול", "waiting": "ממתין לתחילת האתחול...", diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index 3429715ee0..c9ff1e8443 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -2187,6 +2187,25 @@ "cannotDeleteDesc": "Agent bawaan memerlukan setidaknya satu konfigurasi.", "noAgent": "Silakan pilih Agent untuk dikelola." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Inisialisasi terminal", "waiting": "Menunggu inisialisasi dimulai...", diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index 787543e30c..c77e50e827 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -2187,6 +2187,25 @@ "cannotDeleteDesc": "Gli Agent integrati richiedono almeno una configurazione.", "noAgent": "Seleziona l'Agent da gestire." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Terminale inizializzazione", "waiting": "In attesa dell'avvio inizializzazione...", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 22571d83e3..69adcc213b 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -1854,6 +1854,25 @@ "initializeFailed": "初期化に失敗しました", "initializeSuccess": "初期化を開始しました", "initializing": "初期化中...", + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "close": "閉鎖", "closing": "閉会中...", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 552a4f6b61..caa2762094 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -1854,6 +1854,25 @@ "initializeFailed": "초기화 실패", "initializeSuccess": "초기화가 시작되었습니다", "initializing": "초기화 중...", + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "close": "폐쇄", "closing": "폐쇄...", diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index aa703cb753..6409bd3a85 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -2187,6 +2187,25 @@ "cannotDeleteDesc": "Agent terbina dalam memerlukan sekurang-kurangnya satu konfigurasi.", "noAgent": "Sila pilih Agent yang anda ingin uruskan." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Mulakan terminal", "waiting": "Menunggu permulaan untuk bermula...", diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index f05063b21c..4854ec4c19 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -2168,6 +2168,25 @@ "cannotDeleteDesc": "Wbudowani agenci wymagają co najmniej jednej konfiguracji.", "noAgent": "Wybierz agenta, aby zarządzać jego profilami." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Terminal inicjujący", "waiting": "Oczekiwanie na rozpoczęcie inicjalizacji...", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index b6a3d3cda8..6d76c8b8cd 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -1854,6 +1854,25 @@ "initializeFailed": "Falha na inicialização", "initializeSuccess": "Inicialização iniciada", "initializing": "Inicializando...", + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "close": "encerramento", "closing": "Fechando...", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 1f1cb51132..59ba61be32 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -1854,6 +1854,25 @@ "initializeFailed": "Инициализация не удалась", "initializeSuccess": "Инициализация началась", "initializing": "Инициализация...", + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "close": "закрытие", "closing": "Закрытие...", diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index d011d2d8bf..ad84baf652 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -2187,6 +2187,25 @@ "cannotDeleteDesc": "Yerleşik agents en az bir yapılandırma gerektirir.", "noAgent": "Profillerini yönetmek için bir agent seçin." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Başlatma Terminali", "waiting": "Başlatmanın başlaması bekleniyor...", diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index 858dd7a2e8..5bc3f952ff 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -2168,6 +2168,25 @@ "cannotDeleteDesc": "Các tác nhân tích hợp yêu cầu ít nhất một cấu hình.", "noAgent": "Chọn một đại lý để quản lý hồ sơ của nó." }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "title": "Thiết bị đầu cuối khởi tạo", "waiting": "Đang chờ quá trình khởi tạo bắt đầu...", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index aeddf98ae1..fb1227f428 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -2241,6 +2241,25 @@ "cannotDeleteDesc": "内置 Agent 至少需要一套配置。", "noAgent": "请选择要管理的 Agent。" }, + "auth": { + "title": "登录 {name}", + "description": "选择认证方式。凭据仅由 Agent 进程处理。", + "requiredTitle": "{name} 需要登录", + "requiredDescription": "开始聊天前请先完成认证。", + "checkSignIn": "检查登录", + "openTerminal": "打开终端", + "cancelSignIn": "取消登录", + "unsupported": "DeepChat 暂不支持此认证方式,请手动配置所需环境变量。", + "noMethods": "Agent 没有提供 DeepChat 可用的认证方式。", + "status": { + "required": "需要认证", + "running": "正在登录", + "reconnecting": "正在重新连接", + "succeeded": "已就绪", + "cancelled": "已取消", + "failed": "失败" + } + }, "terminal": { "title": "初始化终端", "waiting": "等待初始化开始...", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index b242d0eee9..3b1fb1b711 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -1854,6 +1854,25 @@ "initializeFailed": "初始化失敗", "initializeSuccess": "初始化已啟動", "initializing": "正在初始化...", + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "close": "關閉", "closing": "正在關閉...", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 7d472b17bd..77cc6e3ed7 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -1863,6 +1863,25 @@ "copied": "已複製到剪貼板", "copyFailed": "複製失敗" }, + "auth": { + "title": "Sign in to {name}", + "description": "Choose an authentication method. Credentials stay with the agent process.", + "requiredTitle": "{name} needs sign-in", + "requiredDescription": "Authenticate before starting this chat.", + "checkSignIn": "Check sign-in", + "openTerminal": "Open terminal", + "cancelSignIn": "Cancel sign-in", + "unsupported": "This authentication method is not supported. Configure its environment variables manually.", + "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "status": { + "required": "Authentication required", + "running": "Signing in", + "reconnecting": "Reconnecting", + "succeeded": "Ready", + "cancelled": "Cancelled", + "failed": "Failed" + } + }, "terminal": { "close": "關閉", "closing": "正在關閉...", diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index c247456542..0079eb3d88 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -107,7 +107,7 @@ :is-acp-session="isAcpSelectedAgent" :supports-vision="composerSupportsVision" :editable="!isSubmittingInput" - :submit-disabled="isAcpWorkdirUnavailable || isSubmittingInput" + :submit-disabled="isAcpWorkdirUnavailable || isSubmittingInput || isAcpAuthRequired" :is-attachment-preparation-pending="isPreparingAttachments" @update:files="onFilesChange" @pending-skills-change="onPendingSkillsChange" @@ -124,7 +124,12 @@ :show-search="isSearchAvailable" :search-enabled="isSearchEnabled" :has-input="hasDraftInput" - :send-disabled="isAcpWorkdirUnavailable || isSubmittingInput || !hasDraftInput" + :send-disabled=" + isAcpWorkdirUnavailable || + isSubmittingInput || + isAcpAuthRequired || + !hasDraftInput + " :is-preparing-attachments="isPreparingAttachments" @attach="onAttach" @toggle-search="toggleSearch" @@ -136,6 +141,23 @@ +
+
+
+ {{ t('settings.acp.auth.requiredTitle', { name: acpAuthChallenge.agentName }) }} +
+
+ {{ t('settings.acp.auth.requiredDescription') }} +
+
+ + {{ t('settings.mcp.authenticate') }} + +
+ @@ -161,6 +183,12 @@ @expert="handleActiveChatGuideExpert" @primary="handleActiveChatGuidePrimary" /> + + @@ -186,6 +214,7 @@ import { Icon } from '@iconify/vue' import ChatInputBox from '@/components/chat/ChatInputBox.vue' import ChatInputToolbar from '@/components/chat/ChatInputToolbar.vue' import ChatStatusBar from '@/components/chat/ChatStatusBar.vue' +import AcpAuthDialog from '@/components/acp/AcpAuthDialog.vue' import { openChatStatusBarModelPicker, switchAttachmentToVisionModel, @@ -214,6 +243,7 @@ import type { UserMessageInlineItem, SessionGenerationSettings } from '@shared/types/agent-interface' +import type { AcpAuthChallenge } from '@shared/types/acp' import { normalizeDeepChatSubagentConfig } from '@shared/lib/deepchatSubagents' import { resolveChatModelByQuery, @@ -258,6 +288,9 @@ const attachedFiles = ref([]) const pendingSkills = ref([]) const isSubmittingInput = ref(false) const isPreparingAttachments = ref(false) +const acpAuthChallenge = ref(null) +const acpAuthDialogOpen = ref(false) +const isAcpAuthRequired = computed(() => Boolean(acpAuthChallenge.value)) const activeSubmission = ref(null) const guideRootRef = ref(null) const agentGuideTargetRef = ref(null) @@ -1338,7 +1371,11 @@ async function handleOpenFolderPicker() { } } -const ensureAcpDraftSession = async (agentId: string, projectPath: string) => { +const ensureAcpDraftSession = async ( + agentId: string, + projectPath: string, + openAuthDialog = true +) => { const projectDir = projectPath.trim() if (!projectDir) return @@ -1355,7 +1392,7 @@ const ensureAcpDraftSession = async (agentId: string, projectPath: string) => { const requestSeq = ++acpDraftRequestSeq.value try { - const session = await sessionClient.ensureAcpDraftSession({ + const result = await sessionClient.ensureAcpDraftSession({ agentId, projectDir, permissionMode: draftStore.permissionMode @@ -1368,6 +1405,15 @@ const ensureAcpDraftSession = async (agentId: string, projectPath: string) => { if (currentAgentId !== agentId || currentProjectDir !== projectDir) { return } + if (result.status === 'auth_required') { + acpDraftSessionId.value = result.session.id + acpDraftModelSelection.value = { providerId: 'acp', modelId: agentId } + acpAuthChallenge.value = result.challenge + acpAuthDialogOpen.value = openAuthDialog + lastAcpDraftKey.value = null + return + } + const session = result.session const sessionId = typeof session?.id === 'string' ? session.id.trim() : '' if (!sessionId) { console.warn('[NewThreadPage] ensureAcpDraftSession returned invalid session:', session) @@ -1384,6 +1430,8 @@ const ensureAcpDraftSession = async (agentId: string, projectPath: string) => { session.modelId.trim() ? { providerId: session.providerId.trim(), modelId: session.modelId.trim() } : { providerId: 'acp', modelId: agentId } + acpAuthChallenge.value = null + acpAuthDialogOpen.value = false lastAcpDraftKey.value = draftKey } catch (error) { if (requestSeq !== acpDraftRequestSeq.value) { @@ -1443,6 +1491,8 @@ watch( acpDraftSessionId.value = null acpDraftModelSelection.value = null lastAcpDraftKey.value = null + acpAuthChallenge.value = null + acpAuthDialogOpen.value = false return } cancelEnsureDraftTask = scheduleStartupDeferredTask(async () => { @@ -1452,6 +1502,15 @@ watch( { immediate: true } ) +async function handleAcpAuthSucceeded() { + const agentId = agentStore.selectedAgentId + const projectPath = projectStore.selectedProject?.path?.trim() + acpAuthDialogOpen.value = false + acpAuthChallenge.value = null + if (!agentId || !projectPath) return + await ensureAcpDraftSession(agentId, projectPath, false) +} + watch( [ () => selectedAgent.value.id, diff --git a/src/shared/contracts/events.ts b/src/shared/contracts/events.ts index 833df5183f..e2a6040e1a 100644 --- a/src/shared/contracts/events.ts +++ b/src/shared/contracts/events.ts @@ -1,12 +1,6 @@ import type { z } from 'zod' import type { EventContract } from './common' -import { - acpTerminalErrorEvent, - acpTerminalExitedEvent, - acpTerminalExternalDependenciesRequiredEvent, - acpTerminalOutputEvent, - acpTerminalStartedEvent -} from './events/acp-terminal.events' +import { acpAuthOutputEvent, acpAuthStateChangedEvent } from './events/acp-auth.events' import { approvalClosedEvent, approvalRequestedEvent } from './events/approvals.events' import { appRuntimeGuidedOnboardingResumeRequestedEvent, @@ -152,7 +146,7 @@ import { export * from './events/browser.events' export * from './events/computerUse.events' -export * from './events/acp-terminal.events' +export * from './events/acp-auth.events' export * from './events/approvals.events' export * from './events/app-runtime.events' export * from './events/chat.events' @@ -202,11 +196,8 @@ export const DEEPCHAT_EVENT_CATALOG = { [settingsProviderInstallRequestedEvent.name]: settingsProviderInstallRequestedEvent, [settingsCheckForUpdatesRequestedEvent.name]: settingsCheckForUpdatesRequestedEvent, [semanticNotificationEvent.name]: semanticNotificationEvent, - [acpTerminalStartedEvent.name]: acpTerminalStartedEvent, - [acpTerminalOutputEvent.name]: acpTerminalOutputEvent, - [acpTerminalExitedEvent.name]: acpTerminalExitedEvent, - [acpTerminalErrorEvent.name]: acpTerminalErrorEvent, - [acpTerminalExternalDependenciesRequiredEvent.name]: acpTerminalExternalDependenciesRequiredEvent, + [acpAuthOutputEvent.name]: acpAuthOutputEvent, + [acpAuthStateChangedEvent.name]: acpAuthStateChangedEvent, [appRuntimeStartDeeplinkRequestedEvent.name]: appRuntimeStartDeeplinkRequestedEvent, [appRuntimeMcpInstallRequestedEvent.name]: appRuntimeMcpInstallRequestedEvent, [appRuntimeGuidedOnboardingStartRequestedEvent.name]: diff --git a/src/shared/contracts/events/acp-auth.events.ts b/src/shared/contracts/events/acp-auth.events.ts new file mode 100644 index 0000000000..3cc83edbf2 --- /dev/null +++ b/src/shared/contracts/events/acp-auth.events.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { TimestampMsSchema, defineEventContract } from '../common' +import { AcpAuthRunStateSchema } from '../routes/acp-auth.routes' + +export const acpAuthOutputEvent = defineEventContract({ + name: 'acpAuth.output', + payload: z.object({ + challengeId: z.string().min(1), + runId: z.string().min(1), + data: z.string().max(65_536), + version: TimestampMsSchema + }) +}) + +export const acpAuthStateChangedEvent = defineEventContract({ + name: 'acpAuth.stateChanged', + payload: z.object({ + challengeId: z.string().min(1), + runId: z.string().min(1).optional(), + state: AcpAuthRunStateSchema, + error: z.string().optional(), + version: TimestampMsSchema + }) +}) diff --git a/src/shared/contracts/events/acp-terminal.events.ts b/src/shared/contracts/events/acp-terminal.events.ts deleted file mode 100644 index 5115e851ca..0000000000 --- a/src/shared/contracts/events/acp-terminal.events.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { z } from 'zod' -import { TimestampMsSchema, defineEventContract } from '../common' - -const AcpExternalDependencySchema = z.object({ - name: z.string().min(1), - description: z.string(), - platform: z.array(z.string()).optional(), - checkCommand: z.string().optional(), - checkPaths: z.array(z.string()).optional(), - installCommands: z - .object({ - winget: z.string().optional(), - chocolatey: z.string().optional(), - scoop: z.string().optional() - }) - .optional(), - downloadUrl: z.string().optional(), - requiredFor: z.array(z.string()).optional() -}) - -export const acpTerminalStartedEvent = defineEventContract({ - name: 'acpTerminal.started', - payload: z.object({ - command: z.string(), - version: TimestampMsSchema - }) -}) - -export const acpTerminalOutputEvent = defineEventContract({ - name: 'acpTerminal.output', - payload: z.object({ - type: z.string(), - data: z.string(), - version: TimestampMsSchema - }) -}) - -export const acpTerminalExitedEvent = defineEventContract({ - name: 'acpTerminal.exited', - payload: z.object({ - code: z.number().nullable(), - signal: z.string().nullable(), - version: TimestampMsSchema - }) -}) - -export const acpTerminalErrorEvent = defineEventContract({ - name: 'acpTerminal.error', - payload: z.object({ - message: z.string(), - version: TimestampMsSchema - }) -}) - -export const acpTerminalExternalDependenciesRequiredEvent = defineEventContract({ - name: 'acpTerminal.externalDependenciesRequired', - payload: z.object({ - agentId: z.string().min(1), - missingDeps: z.array(AcpExternalDependencySchema), - version: TimestampMsSchema - }) -}) diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 2d915edf7b..bfcfbb1228 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -7,7 +7,13 @@ import { artifactsReadRoute } from './routes/artifacts.routes' import { audioTranscribeArtifactRoute, audioTranscribeUploadRoute } from './routes/audio.routes' -import { acpTerminalInputRoute, acpTerminalKillRoute } from './routes/acp-terminal.routes' +import { + acpAuthCancelRoute, + acpAuthInputRoute, + acpAuthInspectRoute, + acpAuthStartRoute, + acpAuthStatusRoute +} from './routes/acp-auth.routes' import { browserAttachCurrentWindowRoute, browserApplyImportRoute, @@ -634,7 +640,7 @@ export * from './routes/approvals.routes' export * from './routes/artifacts.routes' export * from './routes/audio.routes' export * from './routes/computerUse.routes' -export * from './routes/acp-terminal.routes' +export * from './routes/acp-auth.routes' export * from './routes/chat.routes' export * from './routes/config.routes' export * from './routes/database-security.routes' @@ -681,8 +687,11 @@ export * from './routes/orchestration.routes' // 既绕过上限又保留逐路由精确的输入/输出类型。新增路由追加到任意一块即可,保持各块体量适中。 const DEEPCHAT_ROUTE_CATALOG_PART_1 = { [approvalsResolveRoute.name]: approvalsResolveRoute, - [acpTerminalInputRoute.name]: acpTerminalInputRoute, - [acpTerminalKillRoute.name]: acpTerminalKillRoute, + [acpAuthInspectRoute.name]: acpAuthInspectRoute, + [acpAuthStartRoute.name]: acpAuthStartRoute, + [acpAuthInputRoute.name]: acpAuthInputRoute, + [acpAuthCancelRoute.name]: acpAuthCancelRoute, + [acpAuthStatusRoute.name]: acpAuthStatusRoute, [shortcutRegisterRoute.name]: shortcutRegisterRoute, [shortcutUnregisterRoute.name]: shortcutUnregisterRoute, [shortcutDestroyRoute.name]: shortcutDestroyRoute, diff --git a/src/shared/contracts/routes/acp-auth.routes.ts b/src/shared/contracts/routes/acp-auth.routes.ts new file mode 100644 index 0000000000..8e2eff81d9 --- /dev/null +++ b/src/shared/contracts/routes/acp-auth.routes.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' +import { defineRouteContract } from '../common' + +const AcpAuthMethodSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + type: z.enum(['agent', 'terminal', 'unsupported']) +}) + +export const AcpAuthChallengeSchema = z.object({ + id: z.string().min(1), + agentId: z.string().min(1), + agentName: z.string().min(1), + workdir: z.string().min(1), + methods: z.array(AcpAuthMethodSchema), + origin: z.enum(['draft_session', 'session_prepare', 'settings_probe']), + sessionId: z.string().min(1).optional() +}) + +export const AcpAuthRunStateSchema = z.enum([ + 'required', + 'running', + 'reconnecting', + 'succeeded', + 'cancelled', + 'failed' +]) + +export const AcpAuthRunStatusSchema = z.object({ + challengeId: z.string().min(1), + runId: z.string().min(1).optional(), + state: AcpAuthRunStateSchema, + error: z.string().optional() +}) + +export const acpAuthInspectRoute = defineRouteContract({ + name: 'acpAuth.inspect', + input: z.object({ + agentId: z.string().min(1), + workdir: z.string().optional() + }), + output: z.object({ + challenge: AcpAuthChallengeSchema + }) +}) + +export const acpAuthStartRoute = defineRouteContract({ + name: 'acpAuth.start', + input: z.object({ + challengeId: z.string().min(1), + methodId: z.string().min(1) + }), + output: AcpAuthRunStatusSchema +}) + +export const acpAuthInputRoute = defineRouteContract({ + name: 'acpAuth.input', + input: z.object({ + runId: z.string().min(1), + data: z.string().min(1).max(16_384) + }), + output: z.object({ + sent: z.literal(true) + }) +}) + +export const acpAuthCancelRoute = defineRouteContract({ + name: 'acpAuth.cancel', + input: z.object({ + runId: z.string().min(1) + }), + output: z.object({ + cancelled: z.boolean() + }) +}) + +export const acpAuthStatusRoute = defineRouteContract({ + name: 'acpAuth.status', + input: z.object({ + challengeId: z.string().min(1) + }), + output: AcpAuthRunStatusSchema +}) diff --git a/src/shared/contracts/routes/acp-terminal.routes.ts b/src/shared/contracts/routes/acp-terminal.routes.ts deleted file mode 100644 index feb0bccffd..0000000000 --- a/src/shared/contracts/routes/acp-terminal.routes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from 'zod' -import { defineRouteContract } from '../common' - -export const acpTerminalInputRoute = defineRouteContract({ - name: 'acpTerminal.input', - input: z.object({ - data: z.string() - }), - output: z.object({ - sent: z.literal(true) - }) -}) - -export const acpTerminalKillRoute = defineRouteContract({ - name: 'acpTerminal.kill', - input: z.object({}).default({}), - output: z.object({ - killed: z.literal(true) - }) -}) diff --git a/src/shared/contracts/routes/sessions.routes.ts b/src/shared/contracts/routes/sessions.routes.ts index 6972a7bbdb..81df01ed5f 100644 --- a/src/shared/contracts/routes/sessions.routes.ts +++ b/src/shared/contracts/routes/sessions.routes.ts @@ -60,6 +60,7 @@ import { import type { RouteContract } from '../common' import { AcpConfigStateSchema, UsageDashboardDataSchema } from '../domainSchemas' import { PROGRAMMATIC_TOOL_BATCH_MAX_STEPS } from './tools.routes' +import { AcpAuthChallengeSchema } from './acp-auth.routes' const PendingSessionInputRecordSchema = z.custom() const MessageTraceRecordSchema = z.custom() @@ -452,9 +453,17 @@ export const sessionsEnsureAcpDraftRoute = defineRouteContract({ projectDir: z.string().min(1), permissionMode: PermissionModeSchema.optional() }), - output: z.object({ - session: SessionWithStateSchema - }) + output: z.discriminatedUnion('status', [ + z.object({ + status: z.literal('ready'), + session: SessionWithStateSchema + }), + z.object({ + status: z.literal('auth_required'), + session: SessionWithStateSchema, + challenge: AcpAuthChallengeSchema + }) + ]) }) export const sessionsListPendingInputsRoute = defineRouteContract({ diff --git a/src/shared/types/acp.ts b/src/shared/types/acp.ts index 626846d997..6c3d37434c 100644 --- a/src/shared/types/acp.ts +++ b/src/shared/types/acp.ts @@ -225,6 +225,42 @@ export interface AcpResolvedLaunchSpec { installDir?: string | null } +export type AcpAuthMethodType = 'agent' | 'terminal' | 'unsupported' + +export interface AcpAuthMethodView { + id: string + name: string + description?: string + type: AcpAuthMethodType +} + +export type AcpAuthChallengeOrigin = 'draft_session' | 'session_prepare' | 'settings_probe' + +export interface AcpAuthChallenge { + id: string + agentId: string + agentName: string + workdir: string + methods: AcpAuthMethodView[] + origin: AcpAuthChallengeOrigin + sessionId?: string +} + +export type AcpAuthRunState = + | 'required' + | 'running' + | 'reconnecting' + | 'succeeded' + | 'cancelled' + | 'failed' + +export interface AcpAuthRunStatus { + challengeId: string + runId?: string + state: AcpAuthRunState + error?: string +} + export interface AcpSessionEntity { id: number conversationId: string diff --git a/test/main/agent/acp/auth/acpAuthService.test.ts b/test/main/agent/acp/auth/acpAuthService.test.ts new file mode 100644 index 0000000000..25b3bfdb1d --- /dev/null +++ b/test/main/agent/acp/auth/acpAuthService.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AcpAuthChallenge } from '@shared/types/acp' + +const ptyMock = vi.hoisted(() => ({ + spawn: vi.fn(), + dataListeners: [] as Array<(data: string) => void>, + exitListeners: [] as Array<(event: { exitCode: number; signal?: number }) => void>, + write: vi.fn(), + kill: vi.fn() +})) + +vi.mock('node-pty', () => ({ + spawn: ptyMock.spawn +})) + +import { AcpAuthService } from '@/agent/acp/auth/acpAuthService' + +const terminalChallenge: AcpAuthChallenge = { + id: 'challenge-1', + agentId: 'agent-1', + agentName: 'Agent One', + workdir: '/workspace', + methods: [{ id: 'terminal-login', name: 'Terminal login', type: 'terminal' }], + origin: 'draft_session', + sessionId: 'session-1' +} + +function createHarness(challenge: AcpAuthChallenge = terminalChallenge) { + const processManager = { + getAuthChallenge: vi.fn(() => challenge), + authenticateAgent: vi.fn().mockResolvedValue(undefined), + prepareTerminalAuthentication: vi.fn().mockResolvedValue({ + challenge, + launch: { + command: '/usr/bin/agent', + args: ['acp', 'login', '--interactive'], + env: { BASE: '1', TOKEN_SOURCE: 'terminal' }, + cwd: '/workspace' + } + }), + completeTerminalAuthentication: vi.fn().mockResolvedValue(undefined), + abandonAuthentication: vi.fn() + } + const sendToRenderer = vi.fn() + let rendererDestroyed: (() => void) | null = null + const onRendererDestroyed = vi.fn((_webContentsId: number, callback: () => void) => { + rendererDestroyed = callback + return vi.fn() + }) + const service = new AcpAuthService({ + owner: { + getOrCreate: () => ({ processManager }) + } as never, + agentSettings: { getAcpAgents: vi.fn().mockResolvedValue([]) }, + sendToRenderer, + onRendererDestroyed + }) + return { + processManager, + sendToRenderer, + service, + destroyRenderer: () => rendererDestroyed?.() + } +} + +describe('AcpAuthService', () => { + beforeEach(() => { + ptyMock.dataListeners.length = 0 + ptyMock.exitListeners.length = 0 + ptyMock.spawn.mockReset() + ptyMock.write.mockReset() + ptyMock.kill.mockReset() + ptyMock.spawn.mockImplementation(() => ({ + pid: 123, + write: ptyMock.write, + kill: ptyMock.kill, + onData: (listener: (data: string) => void) => { + ptyMock.dataListeners.push(listener) + return { dispose: vi.fn() } + }, + onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => { + ptyMock.exitListeners.push(listener) + return { dispose: vi.fn() } + } + })) + }) + + it('direct-spawns terminal auth and reconnects without calling authenticate', async () => { + const harness = createHarness() + + const status = await harness.service.start('challenge-1', 'terminal-login', 42) + + expect(status).toMatchObject({ state: 'running', runId: expect.any(String) }) + expect(ptyMock.spawn).toHaveBeenCalledWith( + '/usr/bin/agent', + ['acp', 'login', '--interactive'], + expect.objectContaining({ + cwd: '/workspace', + env: { BASE: '1', TOKEN_SOURCE: 'terminal' } + }) + ) + expect(harness.processManager.authenticateAgent).not.toHaveBeenCalled() + + ptyMock.dataListeners[0]?.('login output') + expect(harness.sendToRenderer).toHaveBeenCalledWith( + 42, + 'acpAuth.output', + expect.objectContaining({ data: 'login output' }) + ) + + ptyMock.exitListeners[0]?.({ exitCode: 0 }) + await vi.waitFor(() => + expect(harness.processManager.completeTerminalAuthentication).toHaveBeenCalledWith( + 'challenge-1' + ) + ) + await vi.waitFor(() => + expect(harness.service.getStatus('challenge-1', 42)).toMatchObject({ state: 'succeeded' }) + ) + }) + + it('does not reconnect after a non-zero terminal exit', async () => { + const harness = createHarness() + + await harness.service.start('challenge-1', 'terminal-login', 42) + ptyMock.exitListeners[0]?.({ exitCode: 7 }) + + await vi.waitFor(() => + expect(harness.service.getStatus('challenge-1', 42)).toMatchObject({ + state: 'failed', + error: 'Authentication process exited with code 7' + }) + ) + expect(harness.processManager.completeTerminalAuthentication).not.toHaveBeenCalled() + expect(harness.processManager.abandonAuthentication).toHaveBeenCalledWith('challenge-1') + }) + + it('does not reconnect after signal termination', async () => { + const harness = createHarness() + + await harness.service.start('challenge-1', 'terminal-login', 42) + ptyMock.exitListeners[0]?.({ exitCode: 0, signal: 15 }) + + await vi.waitFor(() => + expect(harness.service.getStatus('challenge-1', 42)).toMatchObject({ + state: 'failed', + error: 'Authentication process terminated by signal 15' + }) + ) + expect(harness.processManager.completeTerminalAuthentication).not.toHaveBeenCalled() + }) + + it('uses ACP authenticate only for agent-owned methods', async () => { + const challenge: AcpAuthChallenge = { + ...terminalChallenge, + methods: [{ id: 'browser-login', name: 'Browser login', type: 'agent' }] + } + const harness = createHarness(challenge) + + await expect(harness.service.start('challenge-1', 'browser-login', 42)).resolves.toMatchObject({ + state: 'succeeded' + }) + expect(harness.processManager.authenticateAgent).toHaveBeenCalledWith( + 'challenge-1', + 'browser-login' + ) + expect(ptyMock.spawn).not.toHaveBeenCalled() + }) + + it('rejects another renderer and cancels without reconnecting when the owner closes', async () => { + const harness = createHarness() + const status = await harness.service.start('challenge-1', 'terminal-login', 42) + + expect(() => harness.service.write(status.runId!, 99, 'input')).toThrow( + 'belongs to another renderer' + ) + harness.destroyRenderer() + expect(ptyMock.kill).toHaveBeenCalledOnce() + + ptyMock.exitListeners[0]?.({ exitCode: 0 }) + await vi.waitFor(() => + expect(harness.service.getStatus('challenge-1', 42)).toMatchObject({ state: 'cancelled' }) + ) + expect(harness.processManager.completeTerminalAuthentication).not.toHaveBeenCalled() + expect(harness.processManager.abandonAuthentication).toHaveBeenCalledWith('challenge-1') + }) +}) diff --git a/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts b/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts new file mode 100644 index 0000000000..db6c786be2 --- /dev/null +++ b/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { AcpTerminalAuthRunner } from '@/agent/acp/auth/acpTerminalAuthRunner' + +const processEnvironment = (): Record => + Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => Boolean(entry[1])) + ) + +describe('AcpTerminalAuthRunner', () => { + it('runs the authentication command in a PTY with its exact environment', async () => { + const runner = new AcpTerminalAuthRunner() + const output: string[] = [] + const started = runner.start({ + ownerWebContentsId: 7, + launch: { + command: process.execPath, + args: [ + '-e', + "process.stdout.write(process.env.DEEPCHAT_ACP_AUTH_TEST === 'method-wins' ? 'PTY_AUTH_OK' : 'BAD_ENV'); process.exit(process.env.DEEPCHAT_ACP_AUTH_TEST === 'method-wins' ? 0 : 2)" + ], + env: { ...processEnvironment(), DEEPCHAT_ACP_AUTH_TEST: 'method-wins' }, + cwd: process.cwd() + }, + onData: (_runId, data) => output.push(data) + }) + + const exit = await started.completion + + expect(exit).toMatchObject({ exitCode: 0, cancelled: false }) + expect(output.join('')).toContain('PTY_AUTH_OK') + }) +}) diff --git a/test/main/agent/acp/runtime/acpProcessManager.test.ts b/test/main/agent/acp/runtime/acpProcessManager.test.ts index 8251ce7e5b..0e48569f5f 100644 --- a/test/main/agent/acp/runtime/acpProcessManager.test.ts +++ b/test/main/agent/acp/runtime/acpProcessManager.test.ts @@ -80,7 +80,8 @@ describe('AcpProcessManager config cache fallback', () => { command: 'agent', args: [], env: {} - }) + }), + terminalAuthAvailable: true }) const createConfigState = (model = 'gpt-5', mode = 'code') => ({ @@ -132,6 +133,12 @@ describe('AcpProcessManager config cache fallback', () => { boundConversationId: state === 'bound' ? 'conv-1' : undefined, workdir: '/tmp/workspace', configState: createConfigState(), + materializedLaunch: { + command: '/usr/local/bin/agent', + args: ['--acp'], + env: { PATH: '/runtime/bin', TOKEN: 'base' }, + cwd: '/tmp/workspace' + }, launchSignature: JSON.stringify({ command: 'agent', args: [], @@ -139,11 +146,109 @@ describe('AcpProcessManager config cache fallback', () => { cwd: null, distributionType: 'manual', version: null, - installDir: null + installDir: null, + userEnvOverride: {} }) } } + it('builds terminal authentication from the immutable launch snapshot', async () => { + const manager = createManager() + const handle = createProcessHandle(new MockSpawnedChild()) + handle.authMethods = [ + { + id: 'browser-login', + name: 'Browser login', + type: 'terminal', + args: ['auth', '--browser'], + env: { TOKEN: 'method', AUTH_MODE: 'browser' } + } + ] + + const challenge = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + const prepared = await manager.prepareTerminalAuthentication(challenge.id, 'browser-login') + + expect(prepared.launch).toEqual({ + command: '/usr/local/bin/agent', + args: ['--acp', 'auth', '--browser'], + env: { PATH: '/runtime/bin', TOKEN: 'method', AUTH_MODE: 'browser' }, + cwd: '/tmp/workspace' + }) + + handle.materializedLaunch.args.push('--mutated') + handle.materializedLaunch.env.TOKEN = 'mutated' + expect(prepared.launch.args).toEqual(['--acp', 'auth', '--browser']) + expect(prepared.launch.env.TOKEN).toBe('method') + }) + + it('rejects terminal authentication after launch configuration changes', async () => { + const manager = createManager() + const handle = createProcessHandle(new MockSpawnedChild()) + handle.authMethods = [ + { id: 'browser-login', name: 'Browser login', type: 'terminal', args: ['auth'] } + ] + const challenge = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + + ;(manager as any).resolveLaunchSpec.mockResolvedValue({ + agentId: 'agent-1', + source: 'manual', + distributionType: 'manual', + command: 'agent', + args: ['--changed'], + env: {} + }) + + await expect( + manager.prepareTerminalAuthentication(challenge.id, 'browser-login') + ).rejects.toThrow('challenge is stale') + }) + + it('serializes authentication runs for the same agent and workdir', async () => { + const manager = createManager() + const handle = createProcessHandle(new MockSpawnedChild()) + handle.authMethods = [ + { id: 'browser-login', name: 'Browser login', type: 'terminal', args: ['auth'] } + ] + const first = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + const second = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + + await manager.prepareTerminalAuthentication(first.id, 'browser-login') + await expect( + manager.prepareTerminalAuthentication(second.id, 'browser-login') + ).rejects.toThrow('already running for this agent and workdir') + + manager.abandonAuthentication(first.id) + await expect( + manager.prepareTerminalAuthentication(second.id, 'browser-login') + ).resolves.toMatchObject({ challenge: { id: second.id } }) + }) + + it('does not expose terminal methods as supported when the capability is disabled', () => { + const manager = new AcpProcessManager({ + publishEvent: publishDeepchatEventMock, + providerId: 'acp', + resolveLaunchSpec: vi.fn().mockResolvedValue({ + agentId: 'agent-1', + source: 'manual', + distributionType: 'manual', + command: 'agent', + args: [], + env: {} + }), + terminalAuthAvailable: false + }) + const handle = createProcessHandle(new MockSpawnedChild()) + handle.authMethods = [ + { id: 'browser-login', name: 'Browser login', type: 'terminal', args: ['auth'] } + ] + + const challenge = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + + expect(challenge.methods).toEqual([ + { id: 'browser-login', name: 'Browser login', type: 'unsupported' } + ]) + }) + it('falls back to the latest agent config when no scoped handle matches', () => { const manager = createManager() const configState = createConfigState('gpt-5-mini', 'ask') @@ -474,8 +579,6 @@ describe('AcpProcessManager config cache fallback', () => { }) }) - const child = new MockSpawnedChild() - vi.mocked(spawn).mockReturnValue(child as never) vi.spyOn(shellEnvHelper, 'getShellEnvironment').mockResolvedValue({ PATH: '/shell/bin' }) @@ -504,25 +607,28 @@ describe('AcpProcessManager config cache fallback', () => { isDirectory: () => true } as fs.Stats) - await (manager as any).spawnAgentProcess( + const launch = await (manager as any).materializeAgentLaunch( { id: 'agent-1', name: 'Agent One', command: 'agent' }, '/tmp/workspace', - launchSpec + launchSpec, + { + envOverride: { + PATH: '/user/bin', + USER_ONLY: '1' + } + } ) - expect(spawn).toHaveBeenCalled() - const spawnArgs = vi.mocked(spawn).mock.calls[0] - const spawnOptions = spawnArgs?.[2] - const env = spawnOptions?.env as Record + const env = launch.env as Record const pathValue = normalizePathValue((env.PATH || env.Path || '').replace(/;/g, ':')) - expect(spawnArgs?.[0]).toBe('agent') - expect(spawnArgs?.[1]).toEqual([]) - expect(spawnOptions?.cwd).toBe('/tmp/workspace') + expect(launch.command).toBe('agent') + expect(launch.args).toEqual([]) + expect(launch.cwd).toBe('/tmp/workspace') expect(env.LAUNCH_ONLY).toBe('1') expect(env.USER_ONLY).toBe('1') expect(env.ACP_IDE).toBe('deepchat') @@ -565,17 +671,17 @@ describe('AcpProcessManager config cache fallback', () => { try { await expect( - (manager as any).spawnAgentProcess( + (manager as any).materializeAgentLaunch( { id: 'agent-1', name: 'Agent One', command: 'agent' }, '/tmp/missing-workspace', - launchSpec + launchSpec, + undefined ) ).rejects.toThrow('[ACP] workdir "/tmp/missing-workspace" does not exist for agent agent-1') - expect(spawn).not.toHaveBeenCalled() } finally { existsSpy.mockRestore() } diff --git a/test/main/agent/acp/runtime/acpProcessManagerCapabilities.test.ts b/test/main/agent/acp/runtime/acpProcessManagerCapabilities.test.ts index 025a0fa925..69160d61e7 100644 --- a/test/main/agent/acp/runtime/acpProcessManagerCapabilities.test.ts +++ b/test/main/agent/acp/runtime/acpProcessManagerCapabilities.test.ts @@ -3,6 +3,7 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' const sdkMock = vi.hoisted(() => ({ + initialize: vi.fn(), initializeResponse: { protocolVersion: 1, agentInfo: { name: 'Agent One', version: '1.0.0' }, @@ -31,7 +32,7 @@ vi.mock('@agentclientprotocol/sdk', () => ({ PROTOCOL_VERSION: 1, ClientSideConnection: class { closed = new Promise(() => {}) - initialize = vi.fn(async () => sdkMock.initializeResponse) + initialize = sdkMock.initialize } })) @@ -55,15 +56,23 @@ class MockChild extends EventEmitter { describe('AcpProcessManager initialized capabilities', () => { it('carries initialize capabilities into the ready process handle', async () => { + sdkMock.initialize.mockResolvedValue(sdkMock.initializeResponse) const { AcpProcessManager } = await import('@/agent/acp/runtime/acpProcessManager') const manager = new AcpProcessManager({ publishEvent: vi.fn(), providerId: 'acp', - resolveLaunchSpec: vi.fn() + resolveLaunchSpec: vi.fn(), + terminalAuthAvailable: true }) const child = new MockChild() - vi.spyOn(manager as any, 'spawnAgentProcess').mockResolvedValue(child) + vi.spyOn(manager as any, 'materializeAgentLaunch').mockResolvedValue({ + command: 'agent', + args: [], + env: {}, + cwd: '/tmp/workspace' + }) + vi.spyOn(manager as any, 'spawnAgentProcess').mockReturnValue(child) const handle = await (manager as any).spawnProcessOnce( { @@ -80,7 +89,8 @@ describe('AcpProcessManager initialized capabilities', () => { args: [], env: {} }, - 'manual:agent' + 'manual:agent', + undefined ) expect(handle.promptCapabilities).toEqual({ @@ -100,6 +110,11 @@ describe('AcpProcessManager initialized capabilities', () => { expect(handle.supportsSessionClose).toBe(true) expect(handle.supportsSessionFork).toBe(true) expect(handle.authMethods).toEqual([{ id: 'terminal', name: 'Terminal', type: 'terminal' }]) + expect(sdkMock.initialize).toHaveBeenCalledWith( + expect.objectContaining({ + clientCapabilities: expect.objectContaining({ auth: { terminal: true } }) + }) + ) expect(handle.capabilitySnapshot?.supports).toEqual({ loadSession: true, sessionList: true, diff --git a/test/main/agent/acp/runtime/acpSessionManager.test.ts b/test/main/agent/acp/runtime/acpSessionManager.test.ts index 506c7dac54..7a942dfcaa 100644 --- a/test/main/agent/acp/runtime/acpSessionManager.test.ts +++ b/test/main/agent/acp/runtime/acpSessionManager.test.ts @@ -8,6 +8,8 @@ import { } from '@/agent/acp/runtime/acpProcessManager' import { AcpSessionManager } from '@/agent/acp/runtime/acpSessionManager' import { AcpSessionPersistence } from '@/agent/acp/runtime/acpSessionPersistence' +import { RequestError } from '@agentclientprotocol/sdk' +import { AcpAuthenticationRequiredError } from '@/agent/acp/runtime/acpAuthentication' vi.mock('electron', () => ({ app: { @@ -27,6 +29,9 @@ interface HarnessOptions { throwingDetach?: boolean exitOnRegistration?: boolean handles?: AcpProcessHandle[] + resumeError?: Error + loadError?: Error + newError?: Error } function createHarness(options: HarnessOptions = {}) { @@ -43,16 +48,19 @@ function createHarness(options: HarnessOptions = {}) { const connection = { unstable_resumeSession: vi.fn(async () => { calls.push('resume') + if (options.resumeError) throw options.resumeError if (options.resumeRejects) throw new Error('resume failed') return {} as schema.ResumeSessionResponse }), loadSession: vi.fn(async () => { calls.push('load') + if (options.loadError) throw options.loadError if (options.loadRejects) throw new Error('load failed') return {} as schema.LoadSessionResponse }), newSession: vi.fn(async () => { calls.push('new') + if (options.newError) throw options.newError return { sessionId: 'new-session' } as schema.NewSessionResponse }) } @@ -102,7 +110,16 @@ function createHarness(options: HarnessOptions = {}) { return dispose } ), - clearSession: vi.fn((sessionId: string) => exitHandlers.delete(sessionId)) + clearSession: vi.fn((sessionId: string) => exitHandlers.delete(sessionId)), + createAuthChallenge: vi.fn(() => ({ + id: 'challenge-1', + agentId: 'agent1', + agentName: 'Agent 1', + workdir: '/tmp', + methods: [{ id: 'login', name: 'Login', type: 'agent' }], + origin: 'draft_session', + sessionId: 'conv1' + })) } as unknown as AcpProcessManager const sessionPersistence = { resolveWorkdir: (workdir?: string | null) => workdir?.trim() || '/tmp', @@ -157,6 +174,39 @@ function createHarness(options: HarnessOptions = {}) { } describe('AcpSessionManager public error handling', () => { + it.each([ + { + name: 'resume', + persisted: true, + resumeError: RequestError.authRequired(), + expectedCalls: ['resume'] + }, + { + name: 'load', + persisted: true, + resumeError: new Error('resume failed'), + loadError: RequestError.authRequired(), + expectedCalls: ['resume', 'load'] + }, + { + name: 'new', + persisted: false, + newError: RequestError.authRequired(), + expectedCalls: ['new'] + } + ])('stops fallback when $name returns auth_required', async (options) => { + const harness = createHarness(options) + + await expect( + harness.manager.getOrCreateSession('conv1', agent, hooks(), '/tmp') + ).rejects.toMatchObject({ + code: -32000, + challenge: expect.objectContaining({ id: 'challenge-1' }) + }) + expect(harness.calls).toEqual(options.expectedCalls) + expect(harness.unbindProcess).not.toHaveBeenCalled() + }) + it('throws explicit shutdown error when process manager is shutting down', async () => { const { manager } = createHarness({ getConnectionError: new Error( diff --git a/test/main/routes/contracts.test.ts b/test/main/routes/contracts.test.ts index cb8fe5f08a..5ca7cc22c0 100644 --- a/test/main/routes/contracts.test.ts +++ b/test/main/routes/contracts.test.ts @@ -144,8 +144,11 @@ describe('main kernel contracts', () => { expect(routeKeys).toEqual( expect.arrayContaining([ - 'acpTerminal.input', - 'acpTerminal.kill', + 'acpAuth.cancel', + 'acpAuth.input', + 'acpAuth.inspect', + 'acpAuth.start', + 'acpAuth.status', 'browser.attachCurrentWindow', 'browser.clearSandboxData', 'browser.dismissPreview', @@ -2026,11 +2029,8 @@ describe('main kernel contracts', () => { expect(eventKeys).toEqual( expect.arrayContaining([ - 'acpTerminal.error', - 'acpTerminal.exited', - 'acpTerminal.externalDependenciesRequired', - 'acpTerminal.output', - 'acpTerminal.started', + 'acpAuth.output', + 'acpAuth.stateChanged', 'appRuntime.guidedOnboardingResumeRequested', 'appRuntime.guidedOnboardingStartRequested', 'appRuntime.mcpInstallRequested', diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 4dceb89ce6..53366e5e16 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -60,12 +60,6 @@ import { createPlatformRoutes } from '@/platform/routes' import { createHookRoutes } from '@/hook/routes' import { createAppSettingsRoutes } from '@/app/settingsRoutes' import { createAppRoutes } from '@/app/routes' -import { killTerminal, writeToTerminal } from '@/agent/acp/launch/acpInitHelper' - -vi.mock('@/agent/acp/launch/acpInitHelper', () => ({ - writeToTerminal: vi.fn(), - killTerminal: vi.fn() -})) type MockWindow = { id: number @@ -398,7 +392,10 @@ function createRuntime() { createSession: vi.fn().mockResolvedValue({ ...sessionSnapshot, title: 'New Chat' }), createDetachedSession: vi.fn().mockResolvedValue(sessionSnapshot), createSubagentSession: vi.fn().mockResolvedValue(sessionSnapshot), - ensureAcpDraftSession: vi.fn().mockResolvedValue(sessionSnapshot), + ensureAcpDraftSession: vi.fn().mockResolvedValue({ + status: 'ready', + session: sessionSnapshot + }), forkSession: vi.fn().mockResolvedValue(sessionSnapshot), deleteSession: vi.fn().mockResolvedValue(undefined) } @@ -1736,7 +1733,21 @@ function createRuntime() { void sqlitePresenter.recordSettingsActivity(input) } }) - const acpRoutes = createAcpRoutes() + const acpAuth = { + inspect: vi.fn().mockResolvedValue({ + id: 'challenge-1', + agentId: 'agent-1', + agentName: 'Agent One', + workdir: '/tmp', + methods: [], + origin: 'settings_probe' + }), + start: vi.fn().mockResolvedValue({ challengeId: 'challenge-1', state: 'running' }), + write: vi.fn(), + cancel: vi.fn().mockReturnValue(true), + getStatus: vi.fn().mockReturnValue({ challengeId: 'challenge-1', state: 'required' }) + } + const acpRoutes = createAcpRoutes({ auth: acpAuth as never }) const deviceRoutes = createDeviceRoutes({ device: deviceService, resetDataByType: appDataReset.resetDataByType, @@ -1816,6 +1827,7 @@ function createRuntime() { return { settings, + acpAuth, runtime: (() => { const runtime = createRouteDispatcher({ appDatabaseMaintenance, @@ -3209,22 +3221,27 @@ describe('dispatchDeepchatRoute', () => { }) }) - it('dispatches ACP terminal command routes through the terminal helper', async () => { - const { runtime } = createRuntime() + it('dispatches caller-scoped ACP authentication terminal routes', async () => { + const { runtime, acpAuth } = createRuntime() const context = createRendererRouteContext(42, 7) const inputResult = await dispatchDeepchatRoute( runtime, - 'acpTerminal.input', - { data: 'hello\n' }, + 'acpAuth.input', + { runId: 'run-1', data: 'hello\n' }, + context + ) + const cancelResult = await dispatchDeepchatRoute( + runtime, + 'acpAuth.cancel', + { runId: 'run-1' }, context ) - const killResult = await dispatchDeepchatRoute(runtime, 'acpTerminal.kill', {}, context) - expect(writeToTerminal).toHaveBeenCalledWith('hello\n') - expect(killTerminal).toHaveBeenCalledTimes(1) + expect(acpAuth.write).toHaveBeenCalledWith('run-1', 42, 'hello\n') + expect(acpAuth.cancel).toHaveBeenCalledWith('run-1', 42) expect(inputResult).toEqual({ sent: true }) - expect(killResult).toEqual({ killed: true }) + expect(cancelResult).toEqual({ cancelled: true }) }) it('dispatches shortcut routes through ShortcutPresenter', async () => { diff --git a/test/main/session/lifecycle.test.ts b/test/main/session/lifecycle.test.ts index f0caf45067..9d17833656 100644 --- a/test/main/session/lifecycle.test.ts +++ b/test/main/session/lifecycle.test.ts @@ -7,6 +7,7 @@ import type { } from '@shared/types/agent-interface' import { SessionLifecycle, type SessionLifecycleDependencies } from '@/session/lifecycle' import { SessionDeletionGate } from '@/session/deletionGate' +import { AcpAuthenticationRequiredError } from '@/agent/acp/runtime/acpAuthentication' const createRecord = (overrides: Partial = {}): SessionRecord => ({ id: 'existing', @@ -801,7 +802,10 @@ describe('SessionLifecycle', () => { projectDir: '/repo', permissionMode: 'full_access' }) - ).resolves.toMatchObject({ id: 'draft-1', isDraft: true, providerId: 'acp' }) + ).resolves.toMatchObject({ + status: 'ready', + session: { id: 'draft-1', isDraft: true, providerId: 'acp' } + }) expect(harness.sessions.create).not.toHaveBeenCalled() expect(runtime.setPermissionMode).toHaveBeenCalledWith('full_access') @@ -835,7 +839,10 @@ describe('SessionLifecycle', () => { agentId: 'acp-coder', projectDir: '/repo' }) - ).resolves.toMatchObject({ id: 'session-1', isDraft: true }) + ).resolves.toMatchObject({ + status: 'ready', + session: { id: 'session-1', isDraft: true } + }) expect(harness.sessions.create).toHaveBeenCalledOnce() expect(harness.workdir.prepareDirectAcpSession).toHaveBeenCalledWith('session-1') @@ -847,6 +854,39 @@ describe('SessionLifecycle', () => { warn.mockRestore() }) + it('preserves a reusable local draft when ACP preparation requires authentication', async () => { + const harness = createHarness() + const challenge = { + id: 'challenge-1', + agentId: 'acp-coder', + agentName: 'ACP Coder', + workdir: '/repo', + methods: [{ id: 'browser-login', name: 'Browser login', type: 'terminal' as const }], + origin: 'draft_session' as const, + sessionId: 'session-1' + } + harness.workdir.prepareDirectAcpSession.mockRejectedValueOnce( + new AcpAuthenticationRequiredError(challenge) + ) + + await expect( + harness.coordinator.ensureAcpDraftSession({ + agentId: 'acp-coder', + projectDir: '/repo' + }) + ).resolves.toMatchObject({ + status: 'auth_required', + session: { id: 'session-1', isDraft: true }, + challenge: { id: 'challenge-1' } + }) + + expect(harness.sessions.delete).not.toHaveBeenCalled() + expect(harness.projection.notify).toHaveBeenCalledWith({ + sessionIds: ['session-1'], + reason: 'created' + }) + }) + it('deletes a failed fork row and preserves the transcript error when close fails', async () => { const harness = createHarness([createRecord({ id: 'source', title: 'Source' })]) const sourceRuntime = harness.getRuntime('source') diff --git a/test/main/session/session.integration.test.ts b/test/main/session/session.integration.test.ts index 0a68d7ff0a..7f04e43f0b 100644 --- a/test/main/session/session.integration.test.ts +++ b/test/main/session/session.integration.test.ts @@ -2614,7 +2614,7 @@ describe('Session application coordinators', () => { permissionMode: 'full_access' }) - const session = await lifecycle.ensureAcpDraftSession({ + const result = await lifecycle.ensureAcpDraftSession({ agentId: 'acp-coder', projectDir: '/tmp/workspace' }) @@ -2631,8 +2631,10 @@ describe('Session application coordinators', () => { ) expect(directAcpControl.prepare).toHaveBeenCalledOnce() expect(deepChatAgent.processMessage).not.toHaveBeenCalled() - expect(session.isDraft).toBe(true) - expect(session.providerId).toBe('acp') + expect(result.status).toBe('ready') + if (result.status !== 'ready') throw new Error('Expected ready ACP draft session') + expect(result.session.isDraft).toBe(true) + expect(result.session.providerId).toBe('acp') }) it('reuses existing empty draft session for same agent and project', async () => { @@ -2660,15 +2662,17 @@ describe('Session application coordinators', () => { permissionMode: 'full_access' }) - const session = await lifecycle.ensureAcpDraftSession({ + const result = await lifecycle.ensureAcpDraftSession({ agentId: 'acp-coder', projectDir: '/tmp/workspace' }) expect(sqlitePresenter.newSessionsTable.create).not.toHaveBeenCalled() expect(directAcpControl.prepare).toHaveBeenCalledOnce() - expect(session.id).toBe('draft-1') - expect(session.isDraft).toBe(true) + expect(result.status).toBe('ready') + if (result.status !== 'ready') throw new Error('Expected ready ACP draft session') + expect(result.session.id).toBe('draft-1') + expect(result.session.isDraft).toBe(true) }) }) diff --git a/test/renderer/api/clients.test.ts b/test/renderer/api/clients.test.ts index 1b1bc64729..24bd6150fe 100644 --- a/test/renderer/api/clients.test.ts +++ b/test/renderer/api/clients.test.ts @@ -1,7 +1,7 @@ import { isReactive, reactive } from 'vue' import type { DeepchatBridge } from '@shared/contracts/bridge' import type { HooksNotificationsSettings } from '@shared/hooksNotifications' -import { createAcpTerminalClient } from '../../../src/renderer/api/AcpTerminalClient' +import { createAcpAuthClient } from '../../../src/renderer/api/AcpAuthClient' import { createAppRuntimeClient } from '../../../src/renderer/api/AppRuntimeClient' import { createBrowserClient } from '../../../src/renderer/api/BrowserClient' import { createComputerUseClient } from '../../../src/renderer/api/ComputerUseClient' @@ -36,10 +36,24 @@ describe('renderer api clients', () => { .fn() .mockImplementation(async (routeName: string, payload?: Record) => { switch (routeName) { - case 'acpTerminal.input': + case 'acpAuth.inspect': + return { + challenge: { + id: 'challenge-1', + agentId: 'agent-1', + agentName: 'Agent One', + workdir: '/tmp', + methods: [], + origin: 'settings_probe' + } + } + case 'acpAuth.start': + case 'acpAuth.status': + return { challengeId: 'challenge-1', runId: 'run-1', state: 'running' } + case 'acpAuth.input': return { sent: true } - case 'acpTerminal.kill': - return { killed: true } + case 'acpAuth.cancel': + return { cancelled: true } case 'shortcut.register': return { registered: true } case 'shortcut.unregister': @@ -1158,30 +1172,37 @@ describe('renderer api clients', () => { } } - it('routes ACP terminal commands and events through the shared registry names', async () => { + it('routes ACP authentication commands and events through the shared registry names', async () => { const bridge = createBridge() - const client = createAcpTerminalClient(bridge) + const client = createAcpAuthClient(bridge) const listener = vi.fn() - await client.sendInput('hello\n') - await client.kill() - client.onStarted(listener) + await client.inspect('agent-1', '/tmp') + await client.start('challenge-1', 'terminal') + await client.sendInput('run-1', 'hello\n') + await client.cancel('run-1') + await client.getStatus('challenge-1') client.onOutput(listener) - client.onExited(listener) - client.onError(listener) - client.onExternalDependenciesRequired(listener) - - expect(bridge.invoke).toHaveBeenNthCalledWith(1, 'acpTerminal.input', { data: 'hello\n' }) - expect(bridge.invoke).toHaveBeenNthCalledWith(2, 'acpTerminal.kill', {}) - expect(bridge.on).toHaveBeenNthCalledWith(1, 'acpTerminal.started', listener) - expect(bridge.on).toHaveBeenNthCalledWith(2, 'acpTerminal.output', listener) - expect(bridge.on).toHaveBeenNthCalledWith(3, 'acpTerminal.exited', listener) - expect(bridge.on).toHaveBeenNthCalledWith(4, 'acpTerminal.error', listener) - expect(bridge.on).toHaveBeenNthCalledWith( - 5, - 'acpTerminal.externalDependenciesRequired', - listener - ) + client.onStateChanged(listener) + + expect(bridge.invoke).toHaveBeenNthCalledWith(1, 'acpAuth.inspect', { + agentId: 'agent-1', + workdir: '/tmp' + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(2, 'acpAuth.start', { + challengeId: 'challenge-1', + methodId: 'terminal' + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(3, 'acpAuth.input', { + runId: 'run-1', + data: 'hello\n' + }) + expect(bridge.invoke).toHaveBeenNthCalledWith(4, 'acpAuth.cancel', { runId: 'run-1' }) + expect(bridge.invoke).toHaveBeenNthCalledWith(5, 'acpAuth.status', { + challengeId: 'challenge-1' + }) + expect(bridge.on).toHaveBeenNthCalledWith(1, 'acpAuth.output', listener) + expect(bridge.on).toHaveBeenNthCalledWith(2, 'acpAuth.stateChanged', listener) }) it('routes context menu events through the shared registry names', () => { diff --git a/test/renderer/components/AcpAuthDialog.test.ts b/test/renderer/components/AcpAuthDialog.test.ts new file mode 100644 index 0000000000..74b7156dfe --- /dev/null +++ b/test/renderer/components/AcpAuthDialog.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import type { AcpAuthChallenge } from '@shared/types/acp' + +const authClient = vi.hoisted(() => ({ + start: vi.fn(), + sendInput: vi.fn(), + cancel: vi.fn(), + outputListener: null as ((payload: unknown) => void) | null, + stateListener: null as ((payload: unknown) => void) | null +})) +const terminalWrite = vi.hoisted(() => vi.fn()) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + open() {} + onData() {} + write = terminalWrite + dispose() {} + } +})) + +vi.mock('@api/AcpAuthClient', () => ({ + createAcpAuthClient: () => ({ + start: authClient.start, + sendInput: authClient.sendInput, + cancel: authClient.cancel, + onOutput: (listener: (payload: unknown) => void) => { + authClient.outputListener = listener + return vi.fn() + }, + onStateChanged: (listener: (payload: unknown) => void) => { + authClient.stateListener = listener + return vi.fn() + } + }) +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }) +})) + +const passthrough = (name: string) => defineComponent({ name, template: '
' }) + +const baseChallenge = (methods: AcpAuthChallenge['methods']): AcpAuthChallenge => ({ + id: 'challenge-1', + agentId: 'agent-1', + agentName: 'Agent One', + workdir: '/tmp/workspace', + origin: 'settings_probe', + methods +}) + +async function mountDialog(challenge: AcpAuthChallenge) { + const AcpAuthDialog = (await import('@/components/acp/AcpAuthDialog.vue')).default + const wrapper = mount(AcpAuthDialog, { + props: { open: false, challenge }, + global: { + stubs: { + Dialog: passthrough('Dialog'), + DialogContent: passthrough('DialogContent'), + DialogDescription: passthrough('DialogDescription'), + DialogFooter: passthrough('DialogFooter'), + DialogHeader: passthrough('DialogHeader'), + DialogTitle: passthrough('DialogTitle'), + RadioGroup: passthrough('RadioGroup'), + RadioGroupItem: true, + DcButton: passthrough('DcButton') + } + } + }) + await wrapper.setProps({ open: true }) + await flushPromises() + return wrapper +} + +beforeEach(() => { + authClient.start.mockReset() + authClient.sendInput.mockReset() + authClient.cancel.mockReset() + authClient.outputListener = null + authClient.stateListener = null + terminalWrite.mockReset() +}) + +describe('AcpAuthDialog', () => { + it('preselects exactly one supported method', async () => { + const wrapper = await mountDialog( + baseChallenge([ + { id: 'env', name: 'Environment', type: 'unsupported' }, + { id: 'browser', name: 'Browser login', type: 'terminal' } + ]) + ) + + expect((wrapper.vm as any).selectedMethodId).toBe('browser') + }) + + it('requires an explicit choice when multiple methods are supported', async () => { + const wrapper = await mountDialog( + baseChallenge([ + { id: 'agent', name: 'Agent login', type: 'agent' }, + { id: 'browser', name: 'Browser login', type: 'terminal' } + ]) + ) + + expect((wrapper.vm as any).selectedMethodId).toBe('') + }) + + it('emits success only after the selected method succeeds', async () => { + authClient.start.mockResolvedValue({ challengeId: 'challenge-1', state: 'succeeded' }) + const wrapper = await mountDialog( + baseChallenge([{ id: 'agent', name: 'Agent login', type: 'agent' }]) + ) + + await (wrapper.vm as any).startAuthentication() + + expect(authClient.start).toHaveBeenCalledWith('challenge-1', 'agent') + expect(wrapper.emitted('succeeded')).toHaveLength(1) + }) + + it('cancels only the current terminal run', async () => { + const wrapper = await mountDialog( + baseChallenge([{ id: 'browser', name: 'Browser login', type: 'terminal' }]) + ) + ;(wrapper.vm as any).runId = 'run-1' + + await (wrapper.vm as any).cancelAuthentication() + + expect(authClient.cancel).toHaveBeenCalledWith('run-1') + }) + + it('keeps terminal output that arrives before the start response', async () => { + let resolveStart: + | ((value: { challengeId: string; runId: string; state: 'running' }) => void) + | null = null + authClient.start.mockReturnValue( + new Promise((resolve) => { + resolveStart = resolve + }) + ) + const wrapper = await mountDialog( + baseChallenge([{ id: 'browser', name: 'Browser login', type: 'terminal' }]) + ) + + const starting = (wrapper.vm as any).startAuthentication() + authClient.outputListener?.({ + challengeId: 'challenge-1', + runId: 'run-early', + data: 'EARLY_OUTPUT', + version: Date.now() + }) + await flushPromises() + + expect((wrapper.vm as any).runId).toBe('run-early') + expect(terminalWrite).toHaveBeenCalledWith('EARLY_OUTPUT') + + resolveStart?.({ challengeId: 'challenge-1', runId: 'run-early', state: 'running' }) + await starting + }) +}) diff --git a/test/renderer/components/AcpSettings.test.ts b/test/renderer/components/AcpSettings.test.ts index 5c86be6b25..b9b4048444 100644 --- a/test/renderer/components/AcpSettings.test.ts +++ b/test/renderer/components/AcpSettings.test.ts @@ -119,6 +119,7 @@ type SetupOptions = { registryAgents?: AcpRegistryAgent[] manualAgents?: AcpManualAgent[] config?: Record + inspectAuthentication?: ReturnType } async function setup(options: SetupOptions = {}) { @@ -171,6 +172,27 @@ async function setup(options: SetupOptions = {}) { deleteAgentSessions: vi.fn().mockResolvedValue(undefined), moveAgentSessions: vi.fn().mockResolvedValue(undefined) } + const acpAuthClient = { + inspect: + options.inspectAuthentication ?? + vi.fn().mockResolvedValue({ + challenge: { + id: 'challenge-1', + agentId: 'codex-acp', + agentName: 'Codex ACP', + workdir: '/tmp', + origin: 'settings_probe', + methods: [ + { + id: 'browser-login', + name: 'Browser login', + type: 'terminal', + supported: true + } + ] + } + }) + } vi.doMock('@api/ConfigClient', () => ({ createConfigClient: () => configService @@ -178,6 +200,9 @@ async function setup(options: SetupOptions = {}) { vi.doMock('@api/SessionClient', () => ({ createSessionClient: () => sessionClient })) + vi.doMock('@api/AcpAuthClient', () => ({ + createAcpAuthClient: () => acpAuthClient + })) vi.doMock('@renderer-notifications/rendererNotificationPort', () => ({ notifyRenderer })) @@ -230,6 +255,11 @@ async function setup(options: SetupOptions = {}) { DialogHeader: passthrough('DialogHeader'), DialogTitle: passthrough('DialogTitle'), AgentTransferDialog: AgentTransferDialogStub, + AcpAuthDialog: defineComponent({ + name: 'AcpAuthDialog', + props: ['open', 'challenge'], + template: '
{{ challenge?.id }}
' + }), AcpDebugDialog: passthrough('AcpDebugDialog'), AgentMcpSelector: AgentMcpSelectorStub, AcpAgentIcon: passthrough('AcpAgentIcon'), @@ -243,6 +273,7 @@ async function setup(options: SetupOptions = {}) { wrapper, configService, sessionClient, + acpAuthClient, notifyRenderer, discardSharedMcpRetryIntent, settingsLeaveGuard @@ -254,6 +285,19 @@ afterEach(() => { }) describe('AcpSettings', () => { + it('opens the shared authentication dialog for an installed registry agent', async () => { + const { wrapper, acpAuthClient } = await setup({ registryAgents: [installedAgent()] }) + const authButton = wrapper + .findAll('button') + .find((button) => button.text() === 'settings.acp.auth.checkSignIn') + + await authButton!.trigger('click') + await flushPromises() + + expect(acpAuthClient.inspect).toHaveBeenCalledWith('codex-acp') + expect(wrapper.get('[data-testid="acp-auth-dialog"]').text()).toBe('challenge-1') + }) + it('removes an uninstalled registry agent locally without a redundant success toast', async () => { const { wrapper, configService, sessionClient, notifyRenderer } = await setup({ registryAgents: [installedAgent()] diff --git a/test/renderer/components/NewThreadPage.onboarding.test.ts b/test/renderer/components/NewThreadPage.onboarding.test.ts index 79bb46a15a..affc7a23b6 100644 --- a/test/renderer/components/NewThreadPage.onboarding.test.ts +++ b/test/renderer/components/NewThreadPage.onboarding.test.ts @@ -222,6 +222,7 @@ const setup = async () => { attachTo: document.body, global: { stubs: { + AcpAuthDialog: true, TooltipProvider: passthrough('TooltipProvider'), DcButton: { template: '' diff --git a/test/renderer/components/NewThreadPage.test.ts b/test/renderer/components/NewThreadPage.test.ts index 12fc135272..38a71bb189 100644 --- a/test/renderer/components/NewThreadPage.test.ts +++ b/test/renderer/components/NewThreadPage.test.ts @@ -64,7 +64,31 @@ const setup = async (options?: { agentId: string projectDir: string permissionMode?: string - }) => Promise<{ id: string; providerId?: string; modelId?: string } | null> + }) => Promise< + | { + status: 'ready' + session: { id: string; providerId?: string; modelId?: string } + } + | { + status: 'auth_required' + session: { id: string; providerId?: string; modelId?: string } + challenge: { + id: string + agentId: string + agentName: string + workdir: string + origin: 'draft_session' + sessionId: string + methods: Array<{ + id: string + name: string + type: 'agent' | 'terminal' | 'env_var' | 'unsupported' + supported: boolean + }> + } + } + | null + > selectedProject?: { path: string name: string @@ -243,7 +267,7 @@ const setup = async (options?: { ensureAcpDraftSession: vi.fn().mockImplementation( options?.ensureAcpDraftSession ?? (() => { - return Promise.resolve({ id: 'draft-1' }) + return Promise.resolve({ status: 'ready', session: { id: 'draft-1' } }) }) ) } @@ -358,6 +382,7 @@ const setup = async (options?: { DropdownMenuLabel: passthrough('DropdownMenuLabel'), DropdownMenuSeparator: passthrough('DropdownMenuSeparator'), Icon: true, + AcpAuthDialog: true, ChatInputToolbar: true } } @@ -906,9 +931,12 @@ describe('NewThreadPage ACP draft session bootstrap', () => { const { wrapper, sessionStore, modelClient } = await setup({ ensureAcpDraftSession: () => Promise.resolve({ - id: 'draft-1', - providerId: 'acp', - modelId: 'runtime-agent' + status: 'ready', + session: { + id: 'draft-1', + providerId: 'acp', + modelId: 'runtime-agent' + } }), modelCapabilities: { 'acp:runtime-agent': { supportsAudioInput: false } @@ -1315,12 +1343,13 @@ describe('NewThreadPage ACP draft session bootstrap', () => { }) it('ignores stale ensureAcpDraftSession response after agent/workdir switches', async () => { - let resolveOld: ((value: { id: string }) => void) | null = null - let resolveNew: ((value: { id: string }) => void) | null = null - const oldPromise = new Promise<{ id: string }>((resolve) => { + const ready = (id: string) => ({ status: 'ready' as const, session: { id } }) + let resolveOld: ((value: ReturnType) => void) | null = null + let resolveNew: ((value: ReturnType) => void) | null = null + const oldPromise = new Promise>((resolve) => { resolveOld = resolve }) - const newPromise = new Promise<{ id: string }>((resolve) => { + const newPromise = new Promise>((resolve) => { resolveNew = resolve }) @@ -1332,7 +1361,7 @@ describe('NewThreadPage ACP draft session bootstrap', () => { if (agentId === 'acp-agent-2' && projectDir === '/tmp/workspace-2') { return newPromise } - return Promise.resolve({ id: 'unexpected' }) + return Promise.resolve(ready('unexpected')) } }) @@ -1346,15 +1375,62 @@ describe('NewThreadPage ACP draft session bootstrap', () => { projectStore.selectedProject = { path: '/tmp/workspace-2', name: 'workspace-2' } await flushPromises() - resolveOld?.({ id: 'draft-old' }) + resolveOld?.(ready('draft-old')) await flushPromises() expect((wrapper.vm as any).acpDraftSessionId).not.toBe('draft-old') - resolveNew?.({ id: 'draft-new' }) + resolveNew?.(ready('draft-new')) await flushPromises() expect((wrapper.vm as any).acpDraftSessionId).toBe('draft-new') }) + it('preserves the local draft and retries setup after authentication succeeds', async () => { + let callCount = 0 + const ensureAcpDraftSession = vi.fn(async () => { + callCount += 1 + if (callCount === 1) { + return { + status: 'auth_required' as const, + session: { id: 'draft-auth', providerId: 'acp', modelId: 'acp-agent' }, + challenge: { + id: 'challenge-1', + agentId: 'acp-agent', + agentName: 'ACP Agent', + workdir: '/tmp/workspace', + origin: 'draft_session' as const, + sessionId: 'draft-auth', + methods: [ + { + id: 'browser-login', + name: 'Browser login', + type: 'terminal' as const, + supported: true + } + ] + } + } + } + return { status: 'ready' as const, session: { id: 'draft-auth' } } + }) + const { wrapper } = await setup({ ensureAcpDraftSession }) + + expect((wrapper.vm as any).acpDraftSessionId).toBe('draft-auth') + expect((wrapper.vm as any).acpAuthChallenge?.id).toBe('challenge-1') + expect(wrapper.get('[data-testid="chat-input-box"]').attributes('data-submit-disabled')).toBe( + 'true' + ) + + await (wrapper.vm as any).handleAcpAuthSucceeded() + await flushPromises() + + expect(ensureAcpDraftSession).toHaveBeenCalledTimes(2) + expect((wrapper.vm as any).acpDraftSessionId).toBe('draft-auth') + expect((wrapper.vm as any).acpAuthChallenge).toBeNull() + expect(wrapper.get('[data-testid="chat-input-box"]').attributes('data-submit-disabled')).toBe( + 'false' + ) + }) + it('handles null ensureAcpDraftSession result without throwing', async () => { const { wrapper } = await setup({ ensureAcpDraftSession: () => Promise.resolve(null) diff --git a/test/renderer/pages/NewThreadPage.test.ts b/test/renderer/pages/NewThreadPage.test.ts index 59f61cf8a0..56d1271d1a 100644 --- a/test/renderer/pages/NewThreadPage.test.ts +++ b/test/renderer/pages/NewThreadPage.test.ts @@ -240,6 +240,7 @@ const setup = async ( DcButton: { template: '' }, + AcpAuthDialog: true, ChatInputToolbar: true, ChatStatusBar: true, ChatInputBox: { From 3062c3f5c6bf703f60b22625928c316ad856ed11 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Thu, 20 Aug 2026 14:49:39 +0800 Subject: [PATCH 2/4] fix(toolchains): avoid quarantine collisions --- .../baselines/renderer-application-boundaries-baseline.json | 6 +++++- src/main/toolchains/stateStore.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/architecture/baselines/renderer-application-boundaries-baseline.json b/docs/architecture/baselines/renderer-application-boundaries-baseline.json index 2abd4374ca..774dfd4fd5 100644 --- a/docs/architecture/baselines/renderer-application-boundaries-baseline.json +++ b/docs/architecture/baselines/renderer-application-boundaries-baseline.json @@ -135,6 +135,10 @@ "file": "src/renderer/settings/components/AcpDebugDialog.vue", "specifier": "@/stores/uiSettingsStore" }, + { + "file": "src/renderer/settings/components/AcpSettings.vue", + "specifier": "@/components/acp/AcpAuthDialog.vue" + }, { "file": "src/renderer/settings/components/AcpSettings.vue", "specifier": "@/components/agent/AgentTransferDialog.vue" @@ -552,5 +556,5 @@ "specifier": "@/i18n/bootstrap" } ], - "settingsToChatAppImportCount": 125 + "settingsToChatAppImportCount": 126 } diff --git a/src/main/toolchains/stateStore.ts b/src/main/toolchains/stateStore.ts index 4fecf250f4..0046c0743a 100644 --- a/src/main/toolchains/stateStore.ts +++ b/src/main/toolchains/stateStore.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import { closeSync, fsyncSync, @@ -35,7 +36,7 @@ export function loadToolchainState(userDataDir: string): ToolchainPersistedState export function quarantineCorruptState(userDataDir: string): void { const filePath = stateFilePath(userDataDir) - const quarantinePath = `${filePath}.corrupt.${Date.now()}` + const quarantinePath = `${filePath}.corrupt.${Date.now()}.${randomUUID()}` try { renameSync(filePath, quarantinePath) } catch (error) { From b576a3f0d17279f88ec1a7b46594729fec5800ed Mon Sep 17 00:00:00 2001 From: zerob13 Date: Thu, 20 Aug 2026 16:00:10 +0800 Subject: [PATCH 3/4] fix(acp): harden terminal auth lifecycle --- docs/README.md | 2 +- docs/features/acp-terminal-auth/plan.md | 4 +- docs/features/acp-terminal-auth/spec.md | 4 +- src/main/agent/acp/auth/acpAuthService.ts | 39 +++++- .../agent/acp/auth/acpTerminalAuthRunner.ts | 47 ++++++- .../agent/acp/runtime/acpProcessManager.ts | 28 ++-- .../src/components/acp/AcpAuthDialog.vue | 26 +++- .../agent/acp/auth/acpAuthService.test.ts | 33 ++++- .../acp/auth/acpTerminalAuthRunner.test.ts | 45 +++--- .../acpTerminalAuthRunnerLifecycle.test.ts | 81 +++++++++++ .../acp/runtime/acpProcessManager.test.ts | 56 ++++++++ .../acpProcessManagerCapabilities.test.ts | 129 +++++++++--------- .../renderer/components/AcpAuthDialog.test.ts | 40 +++++- 13 files changed, 418 insertions(+), 116 deletions(-) create mode 100644 test/main/agent/acp/auth/acpTerminalAuthRunnerLifecycle.test.ts diff --git a/docs/README.md b/docs/README.md index 6170247145..fe7c9943ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,7 @@ | --- | --- | | [architecture/local-control-plane/](./architecture/local-control-plane/) | CLI V1 已实现;全量测试与生产构建通过,当前平台 unpack 受发布 runtime 下载网络阻塞 | | [features/acp-v1-reliability/](./features/acp-v1-reliability/) | ACP capability、auth、session lifecycle 与 diagnostics 待实施 | -| [features/acp-terminal-auth/](./features/acp-terminal-auth/) | Issue #2144;ACP v1 Preview terminal auth、交互终端、重连与一次性 session retry 待实施 | +| [features/acp-terminal-auth/](./features/acp-terminal-auth/) | Issue #2144;ACP v1 Preview terminal auth、交互终端、重连与一次性 session retry 已实现 | | [features/cua-cross-platform-computer-use/](./features/cua-cross-platform-computer-use/) | 已实现主体,等待 CI platform matrix 验证 | | [features/mcp-oauth-authentication/](./features/mcp-oauth-authentication/) | 已实现主体,等待真实 OAuth smoke | | [architecture/mcp-v2-protocol/](./architecture/mcp-v2-protocol/) | v2 与 legacy wire 已落地,等待外部互操作验证及兼容窗口结束 | diff --git a/docs/features/acp-terminal-auth/plan.md b/docs/features/acp-terminal-auth/plan.md index 462338fa66..288e1e107e 100644 --- a/docs/features/acp-terminal-auth/plan.md +++ b/docs/features/acp-terminal-auth/plan.md @@ -17,8 +17,8 @@ may run during development, but durable tests are selected and added after the b - `src/main/agent/acp/runtime/acpSessionManager.ts`: numeric `auth_required` detection before resume/load/new fallback and retry eligibility. - `src/main/agent/acp/instance/*`: expose `auth_required` without duplicating authentication logic. -- `src/main/agent/acp/launch/*`: replace the unused shell-injection path with a narrow direct PTY - auth runner. +- `src/main/agent/acp/auth/acpTerminalAuthRunner.ts`: replace the unused shell-injection path with a + narrow direct PTY auth runner. - `src/shared/contracts/*` and `src/renderer/api/*`: renderer-safe auth routes/events and client. - `src/renderer/src/pages/NewThreadPage.vue` and `src/renderer/settings/components/AcpSettings.vue`: onboarding/auth-required entry points and one-shot retry. diff --git a/docs/features/acp-terminal-auth/spec.md b/docs/features/acp-terminal-auth/spec.md index 054d15665a..e895c245ea 100644 --- a/docs/features/acp-terminal-auth/spec.md +++ b/docs/features/acp-terminal-auth/spec.md @@ -257,8 +257,8 @@ For `type='terminal'`: 2. keep the existing protocol connection alive while a separate PTY process runs; 3. direct-spawn the materialized command with base args plus method args and the merged env; 4. stream PTY output only to the renderer that started the run and forward bounded user input; -5. on cancellation, window closure, app shutdown, missing exit status, signal termination, or - non-zero exit, terminate the PTY tree and do not retry; +5. cap each PTY run at ten minutes; on timeout, cancellation, window closure, app shutdown, missing + exit status, signal termination, or non-zero exit, terminate the PTY tree and do not retry; 6. on exit status `0`, dispose the old ACP process handle; 7. start a fresh connection with the same agent/workdir and run `initialize` again; 8. do **not** call `authenticate` with the terminal method ID; diff --git a/src/main/agent/acp/auth/acpAuthService.ts b/src/main/agent/acp/auth/acpAuthService.ts index eacf1a6e65..add480b624 100644 --- a/src/main/agent/acp/auth/acpAuthService.ts +++ b/src/main/agent/acp/auth/acpAuthService.ts @@ -5,6 +5,9 @@ import { AcpTerminalAuthRunner } from './acpTerminalAuthRunner' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' type AcpAuthEventName = 'acpAuth.output' | 'acpAuth.stateChanged' +type StoredAuthStatus = AcpAuthRunStatus & { ownerWebContentsId?: number } + +const MAX_AUTH_STATUS_ENTRIES = 100 export interface AcpAuthServiceDependencies { owner: AcpRuntimeOwner @@ -15,17 +18,20 @@ export interface AcpAuthServiceDependencies { export class AcpAuthService { private readonly runner = new AcpTerminalAuthRunner() - private readonly statuses = new Map() + private readonly statuses = new Map() private readonly detachRendererListeners = new Map void>() + private disposed = false constructor(private readonly dependencies: AcpAuthServiceDependencies) {} async inspect(agentId: string, workdir?: string): Promise { + this.ensureActive() const agent = await this.resolveAgent(agentId) const challenge = await this.dependencies.owner .getOrCreate() .processManager.inspectAuthentication(agent, workdir) - this.statuses.set(challenge.id, { + this.ensureActive() + this.rememberStatus({ challengeId: challenge.id, state: 'required' }) @@ -37,6 +43,7 @@ export class AcpAuthService { methodId: string, ownerWebContentsId: number ): Promise { + this.ensureActive() const current = this.statuses.get(challengeId) if (current?.state === 'running' || current?.state === 'reconnecting') { if (current.ownerWebContentsId !== ownerWebContentsId) { @@ -70,10 +77,12 @@ export class AcpAuthService { const prepared = await processManager.prepareTerminalAuthentication(challengeId, methodId) let started: ReturnType try { + this.ensureActive() started = this.runner.start({ ownerWebContentsId, launch: prepared.launch, onData: (runId, data) => { + if (this.disposed) return this.dependencies.sendToRenderer(ownerWebContentsId, 'acpAuth.output', { challengeId, runId, @@ -132,9 +141,12 @@ export class AcpAuthService { } shutdown(): void { + if (this.disposed) return + this.disposed = true this.runner.shutdown() this.detachRendererListeners.forEach((detach) => detach()) this.detachRendererListeners.clear() + this.statuses.clear() } private async finishTerminalAuthentication( @@ -211,7 +223,8 @@ export class AcpAuthService { } private setStatus(ownerWebContentsId: number, status: AcpAuthRunStatus): AcpAuthRunStatus { - this.statuses.set(status.challengeId, { ...status, ownerWebContentsId }) + if (this.disposed) return status + this.rememberStatus({ ...status, ownerWebContentsId }) this.dependencies.sendToRenderer(ownerWebContentsId, 'acpAuth.stateChanged', { ...status, version: Date.now() @@ -219,13 +232,27 @@ export class AcpAuthService { return status } - private publicStatus( - status: AcpAuthRunStatus & { ownerWebContentsId?: number } - ): AcpAuthRunStatus { + private rememberStatus(status: StoredAuthStatus): void { + this.statuses.delete(status.challengeId) + this.statuses.set(status.challengeId, status) + if (this.statuses.size <= MAX_AUTH_STATUS_ENTRIES) return + + for (const [challengeId, candidate] of this.statuses) { + if (candidate.state === 'running' || candidate.state === 'reconnecting') continue + this.statuses.delete(challengeId) + if (this.statuses.size <= MAX_AUTH_STATUS_ENTRIES) return + } + } + + private publicStatus(status: StoredAuthStatus): AcpAuthRunStatus { const { ownerWebContentsId: _ownerWebContentsId, ...result } = status return result } + private ensureActive(): void { + if (this.disposed) throw new Error('ACP authentication service is shut down') + } + private toSafeError(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0, 1_000) } diff --git a/src/main/agent/acp/auth/acpTerminalAuthRunner.ts b/src/main/agent/acp/auth/acpTerminalAuthRunner.ts index 9dc113e7b6..bac99110b8 100644 --- a/src/main/agent/acp/auth/acpTerminalAuthRunner.ts +++ b/src/main/agent/acp/auth/acpTerminalAuthRunner.ts @@ -2,6 +2,9 @@ import { randomUUID } from 'node:crypto' import { spawn, type IPty } from 'node-pty' import type { AcpMaterializedLaunch } from '../runtime/acpProcessManager' +const AUTH_RUN_TIMEOUT_MS = 10 * 60 * 1000 +const AUTH_KILL_GRACE_MS = 2 * 1000 + export interface AcpTerminalAuthExit { exitCode: number signal?: number @@ -13,11 +16,15 @@ interface AcpTerminalAuthRun { pty: IPty cancelled: boolean completion: Promise + timeout: ReturnType | null + forceKillTimeout: ReturnType | null } export class AcpTerminalAuthRunner { private readonly runs = new Map() + constructor(private readonly platform: NodeJS.Platform = process.platform) {} + start(input: { ownerWebContentsId: number launch: AcpMaterializedLaunch @@ -39,7 +46,9 @@ export class AcpTerminalAuthRunner { ownerWebContentsId: input.ownerWebContentsId, pty, cancelled: false, - completion + completion, + timeout: null, + forceKillTimeout: null } this.runs.set(runId, run) const dataSubscription = pty.onData((data) => { @@ -50,9 +59,14 @@ export class AcpTerminalAuthRunner { const exitSubscription = pty.onExit(({ exitCode, signal }) => { dataSubscription.dispose() exitSubscription.dispose() + this.clearTimeouts(run) this.runs.delete(runId) resolveExit({ exitCode, signal, cancelled: run.cancelled }) }) + if (this.runs.get(runId) === run) { + run.timeout = setTimeout(() => this.terminate(runId, run), AUTH_RUN_TIMEOUT_MS) + run.timeout.unref?.() + } return { runId, completion } } @@ -67,8 +81,7 @@ export class AcpTerminalAuthRunner { if (run.ownerWebContentsId !== ownerWebContentsId) { throw new Error('ACP authentication terminal belongs to another renderer') } - run.cancelled = true - run.pty.kill() + this.terminate(runId, run) return true } @@ -81,10 +94,34 @@ export class AcpTerminalAuthRunner { } shutdown(): void { - for (const run of this.runs.values()) { - run.cancelled = true + for (const [runId, run] of this.runs) { + this.terminate(runId, run) + } + } + + private terminate(runId: string, run: AcpTerminalAuthRun): void { + run.cancelled = true + try { run.pty.kill() + } catch {} + if (this.platform === 'win32' || this.runs.get(runId) !== run || run.forceKillTimeout) { + return } + run.forceKillTimeout = setTimeout(() => { + run.forceKillTimeout = null + if (this.runs.get(runId) !== run) return + try { + run.pty.kill('SIGKILL') + } catch {} + }, AUTH_KILL_GRACE_MS) + run.forceKillTimeout.unref?.() + } + + private clearTimeouts(run: AcpTerminalAuthRun): void { + if (run.timeout) clearTimeout(run.timeout) + if (run.forceKillTimeout) clearTimeout(run.forceKillTimeout) + run.timeout = null + run.forceKillTimeout = null } private requireOwnedRun(runId: string, ownerWebContentsId: number): AcpTerminalAuthRun { diff --git a/src/main/agent/acp/runtime/acpProcessManager.ts b/src/main/agent/acp/runtime/acpProcessManager.ts index 12379e9f32..6b79c3f4ac 100644 --- a/src/main/agent/acp/runtime/acpProcessManager.ts +++ b/src/main/agent/acp/runtime/acpProcessManager.ts @@ -753,21 +753,15 @@ export class AcpProcessManager implements AgentProcessManager { + if (expectedType === 'terminal' && !this.terminalAuthAvailable) { + throw new Error('Terminal ACP authentication is unavailable') + } const challenge = this.requireStoredAuthChallenge(challengeId) if (challenge.active) throw new Error('ACP authentication is already running') if (!this.isHandleAlive(challenge.handle)) { throw new Error('ACP authentication challenge is stale') } - const [launchSpec, agentState] = await Promise.all([ - this.resolveLaunchSpec(challenge.agent.id, challenge.public.workdir), - this.getAgentState?.(challenge.agent.id) - ]) - const currentSignature = createLaunchSignature(launchSpec, agentState?.envOverride) - if (currentSignature !== challenge.launchSignature) { - throw new Error('ACP authentication challenge is stale') - } - const scopeKey = this.getAuthScopeKey(challenge.public) const activeChallengeId = this.activeAuthScopes.get(scopeKey) if (activeChallengeId && activeChallengeId !== challenge.public.id) { @@ -780,9 +774,23 @@ export class AcpProcessManager implements AgentProcessManager | null = null let emittedSuccess = false +let authenticationAttempt = 0 const selectedMethod = computed(() => props.challenge?.methods.find((method) => method.id === selectedMethodId.value) @@ -132,6 +133,7 @@ const statusClass = computed(() => ) function resetDialog() { + invalidateAuthenticationAttempt() state.value = 'required' runId.value = null error.value = null @@ -172,16 +174,22 @@ async function ensureTerminal() { async function startAuthentication() { if (!props.challenge || !selectedMethod.value) return + const attempt = ++authenticationAttempt error.value = null state.value = 'running' try { const result = await client.start(props.challenge.id, selectedMethod.value.id) + if (attempt !== authenticationAttempt || !props.open) { + if (result.runId) cancelRun(result.runId) + return + } state.value = result.state runId.value = result.runId ?? null error.value = result.error ?? null if (runId.value) await ensureTerminal() notifySucceeded() } catch (caught) { + if (attempt !== authenticationAttempt) return state.value = 'failed' error.value = caught instanceof Error ? caught.message : String(caught) } @@ -193,12 +201,21 @@ async function cancelAuthentication() { } function handleOpenChange(open: boolean) { - if (!open && authPending.value && runId.value) { - void client.cancel(runId.value) - } + if (!open) invalidateAuthenticationAttempt() emit('update:open', open) } +function invalidateAuthenticationAttempt() { + authenticationAttempt += 1 + const activeRunId = authPending.value ? runId.value : null + runId.value = null + if (activeRunId) cancelRun(activeRunId) +} + +function cancelRun(activeRunId: string) { + void client.cancel(activeRunId).catch(() => {}) +} + function notifySucceeded() { if (state.value !== 'succeeded' || emittedSuccess) return emittedSuccess = true @@ -206,12 +223,14 @@ function notifySucceeded() { } const stopOutput = client.onOutput((payload) => { + if (!props.open) return if (payload.challengeId !== props.challenge?.id) return if (runId.value && payload.runId !== runId.value) return runId.value ??= payload.runId void ensureTerminal().then(() => terminal?.write(payload.data)) }) const stopState = client.onStateChanged((payload) => { + if (!props.open) return if (payload.challengeId !== props.challenge?.id) return if (runId.value && payload.runId && payload.runId !== runId.value) return runId.value = payload.runId ?? runId.value @@ -229,6 +248,7 @@ watch( ) onBeforeUnmount(() => { + invalidateAuthenticationAttempt() stopOutput() stopState() if (terminalInputTimer) clearTimeout(terminalInputTimer) diff --git a/test/main/agent/acp/auth/acpAuthService.test.ts b/test/main/agent/acp/auth/acpAuthService.test.ts index 25b3bfdb1d..e924f9e6e8 100644 --- a/test/main/agent/acp/auth/acpAuthService.test.ts +++ b/test/main/agent/acp/auth/acpAuthService.test.ts @@ -27,7 +27,7 @@ const terminalChallenge: AcpAuthChallenge = { function createHarness(challenge: AcpAuthChallenge = terminalChallenge) { const processManager = { - getAuthChallenge: vi.fn(() => challenge), + getAuthChallenge: vi.fn((challengeId: string) => ({ ...challenge, id: challengeId })), authenticateAgent: vi.fn().mockResolvedValue(undefined), prepareTerminalAuthentication: vi.fn().mockResolvedValue({ challenge, @@ -184,4 +184,35 @@ describe('AcpAuthService', () => { expect(harness.processManager.completeTerminalAuthentication).not.toHaveBeenCalled() expect(harness.processManager.abandonAuthentication).toHaveBeenCalledWith('challenge-1') }) + + it('keeps authentication status recovery bounded', async () => { + const harness = createHarness({ + ...terminalChallenge, + methods: [{ id: 'browser-login', name: 'Browser login', type: 'agent' }] + }) + + for (let index = 0; index <= 100; index += 1) { + await harness.service.start(`challenge-${index}`, 'browser-login', 42) + } + + expect(harness.service.getStatus('challenge-0', 42)).toEqual({ + challengeId: 'challenge-0', + state: 'required' + }) + expect(harness.service.getStatus('challenge-100', 42)).toMatchObject({ state: 'succeeded' }) + }) + + it('suppresses status events after shutdown while preserving process cleanup', async () => { + const harness = createHarness() + await harness.service.start('challenge-1', 'terminal-login', 42) + const eventCount = harness.sendToRenderer.mock.calls.length + + harness.service.shutdown() + ptyMock.exitListeners[0]?.({ exitCode: 0 }) + + await vi.waitFor(() => + expect(harness.processManager.abandonAuthentication).toHaveBeenCalledWith('challenge-1') + ) + expect(harness.sendToRenderer).toHaveBeenCalledTimes(eventCount) + }) }) diff --git a/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts b/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts index db6c786be2..6ceffd3ed4 100644 --- a/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts +++ b/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts @@ -1,32 +1,35 @@ import { describe, expect, it } from 'vitest' import { AcpTerminalAuthRunner } from '@/agent/acp/auth/acpTerminalAuthRunner' -const processEnvironment = (): Record => - Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => Boolean(entry[1])) - ) - describe('AcpTerminalAuthRunner', () => { it('runs the authentication command in a PTY with its exact environment', async () => { + const sentinelName = 'DEEPCHAT_ACP_AUTH_PARENT_ONLY' + const previousSentinel = process.env[sentinelName] + process.env[sentinelName] = 'parent-only' const runner = new AcpTerminalAuthRunner() const output: string[] = [] - const started = runner.start({ - ownerWebContentsId: 7, - launch: { - command: process.execPath, - args: [ - '-e', - "process.stdout.write(process.env.DEEPCHAT_ACP_AUTH_TEST === 'method-wins' ? 'PTY_AUTH_OK' : 'BAD_ENV'); process.exit(process.env.DEEPCHAT_ACP_AUTH_TEST === 'method-wins' ? 0 : 2)" - ], - env: { ...processEnvironment(), DEEPCHAT_ACP_AUTH_TEST: 'method-wins' }, - cwd: process.cwd() - }, - onData: (_runId, data) => output.push(data) - }) + try { + const started = runner.start({ + ownerWebContentsId: 7, + launch: { + command: process.execPath, + args: [ + '-e', + `const valid = process.env.DEEPCHAT_ACP_AUTH_TEST === 'method-wins' && !process.env.${sentinelName}; process.stdout.write(valid ? 'PTY_AUTH_OK' : 'BAD_ENV'); process.exit(valid ? 0 : 2)` + ], + env: { DEEPCHAT_ACP_AUTH_TEST: 'method-wins' }, + cwd: process.cwd() + }, + onData: (_runId, data) => output.push(data) + }) - const exit = await started.completion + const exit = await started.completion - expect(exit).toMatchObject({ exitCode: 0, cancelled: false }) - expect(output.join('')).toContain('PTY_AUTH_OK') + expect(exit).toMatchObject({ exitCode: 0, cancelled: false }) + expect(output.join('')).toContain('PTY_AUTH_OK') + } finally { + if (previousSentinel === undefined) delete process.env[sentinelName] + else process.env[sentinelName] = previousSentinel + } }) }) diff --git a/test/main/agent/acp/auth/acpTerminalAuthRunnerLifecycle.test.ts b/test/main/agent/acp/auth/acpTerminalAuthRunnerLifecycle.test.ts new file mode 100644 index 0000000000..a703624b57 --- /dev/null +++ b/test/main/agent/acp/auth/acpTerminalAuthRunnerLifecycle.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const ptyMock = vi.hoisted(() => ({ + spawn: vi.fn(), + kill: vi.fn(), + exitListener: null as ((event: { exitCode: number; signal?: number }) => void) | null +})) + +vi.mock('node-pty', () => ({ spawn: ptyMock.spawn })) + +import { AcpTerminalAuthRunner } from '@/agent/acp/auth/acpTerminalAuthRunner' + +function startRunner(platform: NodeJS.Platform) { + const runner = new AcpTerminalAuthRunner(platform) + const started = runner.start({ + ownerWebContentsId: 7, + launch: { command: 'agent', args: ['login'], env: {}, cwd: '/workspace' }, + onData: vi.fn() + }) + return { runner, started } +} + +describe('AcpTerminalAuthRunner lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + ptyMock.kill.mockReset() + ptyMock.exitListener = null + ptyMock.spawn.mockReset().mockReturnValue({ + kill: ptyMock.kill, + write: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn((listener) => { + ptyMock.exitListener = listener + return { dispose: vi.fn() } + }) + }) + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + it.each([ + ['cancel', 'darwin', true], + ['cancel', 'win32', false], + ['shutdown', 'darwin', true], + ['shutdown', 'win32', false] + ] as const)('terminates %s runs safely on %s', (_action, platform, shouldEscalate) => { + const { runner, started } = startRunner(platform) + + if (_action === 'cancel') runner.cancel(started.runId, 7) + else runner.shutdown() + + expect(ptyMock.kill).toHaveBeenCalledWith() + vi.advanceTimersByTime(2_000) + if (shouldEscalate) expect(ptyMock.kill).toHaveBeenLastCalledWith('SIGKILL') + else expect(ptyMock.kill).toHaveBeenCalledOnce() + }) + + it('clears termination escalation when the PTY exits', async () => { + const { runner, started } = startRunner('darwin') + + runner.cancel(started.runId, 7) + ptyMock.exitListener?.({ exitCode: 0 }) + await expect(started.completion).resolves.toMatchObject({ cancelled: true }) + vi.advanceTimersByTime(2_000) + + expect(ptyMock.kill).toHaveBeenCalledOnce() + }) + + it('cancels a terminal authentication run after ten minutes', async () => { + const { started } = startRunner('darwin') + + vi.advanceTimersByTime(10 * 60 * 1000) + expect(ptyMock.kill).toHaveBeenCalledWith() + ptyMock.exitListener?.({ exitCode: 0 }) + + await expect(started.completion).resolves.toMatchObject({ cancelled: true }) + }) +}) diff --git a/test/main/agent/acp/runtime/acpProcessManager.test.ts b/test/main/agent/acp/runtime/acpProcessManager.test.ts index 0e48569f5f..3d533f03eb 100644 --- a/test/main/agent/acp/runtime/acpProcessManager.test.ts +++ b/test/main/agent/acp/runtime/acpProcessManager.test.ts @@ -223,6 +223,44 @@ describe('AcpProcessManager config cache fallback', () => { ).resolves.toMatchObject({ challenge: { id: second.id } }) }) + it('reserves a challenge before asynchronous launch validation', async () => { + const manager = createManager() + const handle = createProcessHandle(new MockSpawnedChild()) + handle.authMethods = [ + { id: 'browser-login', name: 'Browser login', type: 'terminal', args: ['auth'] } + ] + const challenge = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + let resolveLaunch!: (value: { + agentId: string + source: 'manual' + distributionType: 'manual' + command: string + args: string[] + env: Record + }) => void + ;(manager as any).resolveLaunchSpec.mockReturnValue( + new Promise((resolve) => { + resolveLaunch = resolve + }) + ) + + const firstStart = manager.prepareTerminalAuthentication(challenge.id, 'browser-login') + await vi.waitFor(() => expect((manager as any).resolveLaunchSpec).toHaveBeenCalledOnce()) + await expect( + manager.prepareTerminalAuthentication(challenge.id, 'browser-login') + ).rejects.toThrow('already running') + + resolveLaunch({ + agentId: 'agent-1', + source: 'manual', + distributionType: 'manual', + command: 'agent', + args: [], + env: {} + }) + await expect(firstStart).resolves.toMatchObject({ challenge: { id: challenge.id } }) + }) + it('does not expose terminal methods as supported when the capability is disabled', () => { const manager = new AcpProcessManager({ publishEvent: publishDeepchatEventMock, @@ -249,6 +287,24 @@ describe('AcpProcessManager config cache fallback', () => { ]) }) + it('rejects terminal authentication when the capability is disabled', async () => { + const manager = new AcpProcessManager({ + publishEvent: publishDeepchatEventMock, + providerId: 'acp', + resolveLaunchSpec: vi.fn(), + terminalAuthAvailable: false + }) + const handle = createProcessHandle(new MockSpawnedChild()) + handle.authMethods = [ + { id: 'browser-login', name: 'Browser login', type: 'terminal', args: ['auth'] } + ] + const challenge = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + + await expect( + manager.prepareTerminalAuthentication(challenge.id, 'browser-login') + ).rejects.toThrow('Terminal ACP authentication is unavailable') + }) + it('falls back to the latest agent config when no scoped handle matches', () => { const manager = createManager() const configState = createConfigState('gpt-5-mini', 'ask') diff --git a/test/main/agent/acp/runtime/acpProcessManagerCapabilities.test.ts b/test/main/agent/acp/runtime/acpProcessManagerCapabilities.test.ts index 69160d61e7..c1f5fa6ef3 100644 --- a/test/main/agent/acp/runtime/acpProcessManagerCapabilities.test.ts +++ b/test/main/agent/acp/runtime/acpProcessManagerCapabilities.test.ts @@ -55,72 +55,73 @@ class MockChild extends EventEmitter { } describe('AcpProcessManager initialized capabilities', () => { - it('carries initialize capabilities into the ready process handle', async () => { - sdkMock.initialize.mockResolvedValue(sdkMock.initializeResponse) - const { AcpProcessManager } = - await import('@/agent/acp/runtime/acpProcessManager') - const manager = new AcpProcessManager({ - publishEvent: vi.fn(), - providerId: 'acp', - resolveLaunchSpec: vi.fn(), - terminalAuthAvailable: true - }) - const child = new MockChild() - vi.spyOn(manager as any, 'materializeAgentLaunch').mockResolvedValue({ - command: 'agent', - args: [], - env: {}, - cwd: '/tmp/workspace' - }) - vi.spyOn(manager as any, 'spawnAgentProcess').mockReturnValue(child) - - const handle = await (manager as any).spawnProcessOnce( - { - id: 'agent-1', - name: 'Agent One', - command: 'agent' - }, - '/tmp/workspace', - { - agentId: 'agent-1', - source: 'manual', - distributionType: 'manual', + it.each([true, false])( + 'carries initialize capabilities into the ready process handle when terminal auth is %s', + async (terminalAuthAvailable) => { + sdkMock.initialize.mockClear() + sdkMock.initialize.mockResolvedValue(sdkMock.initializeResponse) + const { AcpProcessManager } = await import('@/agent/acp/runtime/acpProcessManager') + const manager = new AcpProcessManager({ + publishEvent: vi.fn(), + providerId: 'acp', + resolveLaunchSpec: vi.fn(), + terminalAuthAvailable + }) + const child = new MockChild() + vi.spyOn(manager as any, 'materializeAgentLaunch').mockResolvedValue({ command: 'agent', args: [], - env: {} - }, - 'manual:agent', - undefined - ) + env: {}, + cwd: '/tmp/workspace' + }) + vi.spyOn(manager as any, 'spawnAgentProcess').mockReturnValue(child) + + const handle = await (manager as any).spawnProcessOnce( + { + id: 'agent-1', + name: 'Agent One', + command: 'agent' + }, + '/tmp/workspace', + { + agentId: 'agent-1', + source: 'manual', + distributionType: 'manual', + command: 'agent', + args: [], + env: {} + }, + 'manual:agent', + undefined + ) - expect(handle.promptCapabilities).toEqual({ - image: true, - audio: true, - embeddedContext: true - }) - expect(handle.sessionCapabilities).toEqual({ - list: {}, - resume: {}, - close: {}, - fork: {} - }) - expect(handle.supportsLoadSession).toBe(true) - expect(handle.supportsSessionList).toBe(true) - expect(handle.supportsSessionResume).toBe(true) - expect(handle.supportsSessionClose).toBe(true) - expect(handle.supportsSessionFork).toBe(true) - expect(handle.authMethods).toEqual([{ id: 'terminal', name: 'Terminal', type: 'terminal' }]) - expect(sdkMock.initialize).toHaveBeenCalledWith( - expect.objectContaining({ - clientCapabilities: expect.objectContaining({ auth: { terminal: true } }) + expect(handle.promptCapabilities).toEqual({ + image: true, + audio: true, + embeddedContext: true }) - ) - expect(handle.capabilitySnapshot?.supports).toEqual({ - loadSession: true, - sessionList: true, - sessionResume: true, - sessionClose: true, - sessionFork: true - }) - }) + expect(handle.sessionCapabilities).toEqual({ + list: {}, + resume: {}, + close: {}, + fork: {} + }) + expect(handle.supportsLoadSession).toBe(true) + expect(handle.supportsSessionList).toBe(true) + expect(handle.supportsSessionResume).toBe(true) + expect(handle.supportsSessionClose).toBe(true) + expect(handle.supportsSessionFork).toBe(true) + expect(handle.authMethods).toEqual([{ id: 'terminal', name: 'Terminal', type: 'terminal' }]) + const clientCapabilities = sdkMock.initialize.mock.calls.at(-1)?.[0].clientCapabilities + if (terminalAuthAvailable) expect(clientCapabilities.auth).toEqual({ terminal: true }) + else expect(clientCapabilities.auth).toBeUndefined() + expect(handle.capabilitySnapshot?.supports).toEqual({ + loadSession: true, + sessionList: true, + sessionResume: true, + sessionClose: true, + sessionFork: true + }) + } + ) }) diff --git a/test/renderer/components/AcpAuthDialog.test.ts b/test/renderer/components/AcpAuthDialog.test.ts index 74b7156dfe..09d5cc495d 100644 --- a/test/renderer/components/AcpAuthDialog.test.ts +++ b/test/renderer/components/AcpAuthDialog.test.ts @@ -78,7 +78,7 @@ async function mountDialog(challenge: AcpAuthChallenge) { beforeEach(() => { authClient.start.mockReset() authClient.sendInput.mockReset() - authClient.cancel.mockReset() + authClient.cancel.mockReset().mockResolvedValue({ cancelled: true }) authClient.outputListener = null authClient.stateListener = null terminalWrite.mockReset() @@ -158,4 +158,42 @@ describe('AcpAuthDialog', () => { resolveStart?.({ challengeId: 'challenge-1', runId: 'run-early', state: 'running' }) await starting }) + + it('cancels a terminal run returned after the dialog closes', async () => { + let resolveStart: + | ((value: { challengeId: string; runId: string; state: 'running' }) => void) + | null = null + authClient.start.mockReturnValue( + new Promise((resolve) => { + resolveStart = resolve + }) + ) + const wrapper = await mountDialog( + baseChallenge([{ id: 'browser', name: 'Browser login', type: 'terminal' }]) + ) + + const starting = (wrapper.vm as any).startAuthentication() + ;(wrapper.vm as any).handleOpenChange(false) + resolveStart?.({ challengeId: 'challenge-1', runId: 'run-late', state: 'running' }) + await starting + + expect(authClient.cancel).toHaveBeenCalledWith('run-late') + expect((wrapper.vm as any).runId).toBeNull() + }) + + it('cancels the active terminal run on unmount', async () => { + authClient.start.mockResolvedValue({ + challengeId: 'challenge-1', + runId: 'run-active', + state: 'running' + }) + const wrapper = await mountDialog( + baseChallenge([{ id: 'browser', name: 'Browser login', type: 'terminal' }]) + ) + await (wrapper.vm as any).startAuthentication() + + wrapper.unmount() + + expect(authClient.cancel).toHaveBeenCalledWith('run-active') + }) }) From 7e07c2644fbe65b16b141a96c10044a2fe99cc0f Mon Sep 17 00:00:00 2001 From: zerob13 Date: Thu, 20 Aug 2026 17:37:34 +0800 Subject: [PATCH 4/4] fix(acp): address auth review findings --- src/main/agent/acp/auth/acpAuthService.ts | 85 +++++++++++---- .../agent/acp/auth/acpTerminalAuthRunner.ts | 94 +++++++++++----- src/main/agent/acp/routes.ts | 8 +- .../agent/acp/runtime/acpProcessManager.ts | 99 ++++++++++++++++- .../src/components/acp/AcpAuthDialog.vue | 17 ++- src/renderer/src/i18n/da-DK/settings.json | 30 +++--- src/renderer/src/i18n/he-IL/settings.json | 30 +++--- src/renderer/src/i18n/ru-RU/settings.json | 30 +++--- .../contracts/routes/acp-auth.routes.ts | 3 +- src/shared/types/acp.ts | 1 + .../agent/acp/auth/acpAuthService.test.ts | 42 +++++++- .../acp/auth/acpTerminalAuthRunner.test.ts | 2 +- .../acpTerminalAuthRunnerLifecycle.test.ts | 36 ++++++- .../acp/runtime/acpProcessManager.test.ts | 64 +++++++++++ test/main/routes/dispatcher.test.ts | 8 +- test/renderer/api/clients.test.ts | 7 +- .../renderer/components/AcpAuthDialog.test.ts | 101 ++++++++++++++++-- 17 files changed, 536 insertions(+), 121 deletions(-) diff --git a/src/main/agent/acp/auth/acpAuthService.ts b/src/main/agent/acp/auth/acpAuthService.ts index add480b624..92ecc07a00 100644 --- a/src/main/agent/acp/auth/acpAuthService.ts +++ b/src/main/agent/acp/auth/acpAuthService.ts @@ -1,11 +1,12 @@ import type { AcpAgentConfig, AcpAuthChallenge, AcpAuthRunStatus } from '@shared/types/acp' import type { AgentSettingsPort } from '@/agent/settings' import type { AcpRuntimeOwner } from '../client' -import { AcpTerminalAuthRunner } from './acpTerminalAuthRunner' +import { AcpTerminalAuthRunner, type AcpTerminalAuthExit } from './acpTerminalAuthRunner' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' type AcpAuthEventName = 'acpAuth.output' | 'acpAuth.stateChanged' -type StoredAuthStatus = AcpAuthRunStatus & { ownerWebContentsId?: number } +type AcpAuthStatusInput = Omit +type StoredAuthStatus = AcpAuthRunStatus & { ownerWebContentsId: number } const MAX_AUTH_STATUS_ENTRIES = 100 @@ -20,11 +21,16 @@ export class AcpAuthService { private readonly runner = new AcpTerminalAuthRunner() private readonly statuses = new Map() private readonly detachRendererListeners = new Map void>() + private lastEventVersion = 0 private disposed = false constructor(private readonly dependencies: AcpAuthServiceDependencies) {} - async inspect(agentId: string, workdir?: string): Promise { + async inspect( + agentId: string, + workdir: string | undefined, + ownerWebContentsId: number + ): Promise { this.ensureActive() const agent = await this.resolveAgent(agentId) const challenge = await this.dependencies.owner @@ -33,7 +39,9 @@ export class AcpAuthService { this.ensureActive() this.rememberStatus({ challengeId: challenge.id, - state: 'required' + state: 'required', + version: this.nextEventVersion(), + ownerWebContentsId }) return challenge } @@ -45,10 +53,10 @@ export class AcpAuthService { ): Promise { this.ensureActive() const current = this.statuses.get(challengeId) + if (current && current.ownerWebContentsId !== ownerWebContentsId) { + throw new Error('ACP authentication is owned by another renderer') + } if (current?.state === 'running' || current?.state === 'reconnecting') { - if (current.ownerWebContentsId !== ownerWebContentsId) { - throw new Error('ACP authentication is owned by another renderer') - } return this.publicStatus(current) } @@ -62,8 +70,12 @@ export class AcpAuthService { if (method.type === 'agent') { this.setStatus(ownerWebContentsId, { challengeId, state: 'running' }) + const controller = new AbortController() + const detachRendererListener = this.dependencies.onRendererDestroyed(ownerWebContentsId, () => + controller.abort(new Error('ACP authentication renderer was destroyed')) + ) try { - await processManager.authenticateAgent(challengeId, methodId) + await processManager.authenticateAgent(challengeId, methodId, controller.signal) return this.setStatus(ownerWebContentsId, { challengeId, state: 'succeeded' }) } catch (error) { return this.setStatus(ownerWebContentsId, { @@ -71,6 +83,8 @@ export class AcpAuthService { state: 'failed', error: this.toSafeError(error) }) + } finally { + detachRendererListener() } } @@ -87,7 +101,7 @@ export class AcpAuthService { challengeId, runId, data, - version: Date.now() + version: this.nextEventVersion() }) } }) @@ -122,11 +136,10 @@ export class AcpAuthService { getStatus(challengeId: string, ownerWebContentsId: number): AcpAuthRunStatus { const status = this.statuses.get(challengeId) - if (!status) return { challengeId, state: 'required' } - if ( - status.ownerWebContentsId !== undefined && - status.ownerWebContentsId !== ownerWebContentsId - ) { + if (!status) { + return { challengeId, state: 'required', version: this.nextEventVersion() } + } + if (status.ownerWebContentsId !== ownerWebContentsId) { throw new Error('ACP authentication is owned by another renderer') } return this.publicStatus(status) @@ -153,12 +166,12 @@ export class AcpAuthService { challengeId: string, runId: string, ownerWebContentsId: number, - completion: Promise<{ exitCode: number; signal?: number; cancelled: boolean }> + completion: Promise ): Promise { const processManager = this.dependencies.owner.getOrCreate().processManager try { const exit = await completion - if (exit.cancelled) { + if (exit.reason === 'cancelled') { processManager.abandonAuthentication(challengeId) this.setStatus(ownerWebContentsId, { challengeId, @@ -167,6 +180,26 @@ export class AcpAuthService { }) return } + if (exit.reason === 'timed_out') { + processManager.abandonAuthentication(challengeId) + this.setStatus(ownerWebContentsId, { + challengeId, + runId, + state: 'failed', + error: 'Authentication process timed out' + }) + return + } + if (exit.reason === 'output_limit') { + processManager.abandonAuthentication(challengeId) + this.setStatus(ownerWebContentsId, { + challengeId, + runId, + state: 'failed', + error: 'Authentication process exceeded the output limit' + }) + return + } if (exit.signal) { processManager.abandonAuthentication(challengeId) this.setStatus(ownerWebContentsId, { @@ -222,14 +255,15 @@ export class AcpAuthService { return agent } - private setStatus(ownerWebContentsId: number, status: AcpAuthRunStatus): AcpAuthRunStatus { - if (this.disposed) return status - this.rememberStatus({ ...status, ownerWebContentsId }) - this.dependencies.sendToRenderer(ownerWebContentsId, 'acpAuth.stateChanged', { + private setStatus(ownerWebContentsId: number, status: AcpAuthStatusInput): AcpAuthRunStatus { + const publicStatus: AcpAuthRunStatus = { ...status, - version: Date.now() - }) - return status + version: this.nextEventVersion() + } + if (this.disposed) return publicStatus + this.rememberStatus({ ...publicStatus, ownerWebContentsId }) + this.dependencies.sendToRenderer(ownerWebContentsId, 'acpAuth.stateChanged', publicStatus) + return publicStatus } private rememberStatus(status: StoredAuthStatus): void { @@ -253,6 +287,11 @@ export class AcpAuthService { if (this.disposed) throw new Error('ACP authentication service is shut down') } + private nextEventVersion(): number { + this.lastEventVersion = Math.max(Date.now(), this.lastEventVersion + 1) + return this.lastEventVersion + } + private toSafeError(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0, 1_000) } diff --git a/src/main/agent/acp/auth/acpTerminalAuthRunner.ts b/src/main/agent/acp/auth/acpTerminalAuthRunner.ts index bac99110b8..54ab71d1f9 100644 --- a/src/main/agent/acp/auth/acpTerminalAuthRunner.ts +++ b/src/main/agent/acp/auth/acpTerminalAuthRunner.ts @@ -4,20 +4,27 @@ import type { AcpMaterializedLaunch } from '../runtime/acpProcessManager' const AUTH_RUN_TIMEOUT_MS = 10 * 60 * 1000 const AUTH_KILL_GRACE_MS = 2 * 1000 +const AUTH_EXIT_FALLBACK_MS = 4 * 1000 +const AUTH_OUTPUT_LIMIT_BYTES = 1024 * 1024 + +export type AcpTerminalAuthExitReason = 'exited' | 'cancelled' | 'timed_out' | 'output_limit' export interface AcpTerminalAuthExit { exitCode: number signal?: number - cancelled: boolean + reason: AcpTerminalAuthExitReason } interface AcpTerminalAuthRun { ownerWebContentsId: number pty: IPty - cancelled: boolean + terminationReason: Exclude | null + outputBytes: number completion: Promise timeout: ReturnType | null forceKillTimeout: ReturnType | null + exitFallbackTimeout: ReturnType | null + finish(exitCode: number, signal?: number): void } export class AcpTerminalAuthRunner { @@ -42,29 +49,49 @@ export class AcpTerminalAuthRunner { const completion = new Promise((resolve) => { resolveExit = resolve }) + let dataSubscription: { dispose(): void } | null = null + let exitSubscription: { dispose(): void } | null = null + let settled = false const run: AcpTerminalAuthRun = { ownerWebContentsId: input.ownerWebContentsId, pty, - cancelled: false, + terminationReason: null, + outputBytes: 0, completion, timeout: null, - forceKillTimeout: null + forceKillTimeout: null, + exitFallbackTimeout: null, + finish: (exitCode, signal) => { + if (settled) return + settled = true + dataSubscription?.dispose() + exitSubscription?.dispose() + this.clearTimeouts(run) + if (this.runs.get(runId) === run) this.runs.delete(runId) + resolveExit({ + exitCode, + signal, + reason: run.terminationReason ?? 'exited' + }) + } } this.runs.set(runId, run) - const dataSubscription = pty.onData((data) => { + dataSubscription = pty.onData((data) => { + const nextOutputBytes = run.outputBytes + Buffer.byteLength(data) + if (nextOutputBytes > AUTH_OUTPUT_LIMIT_BYTES) { + this.terminate(runId, run, 'output_limit') + return + } + run.outputBytes = nextOutputBytes for (let offset = 0; offset < data.length; offset += 65_536) { input.onData(runId, data.slice(offset, offset + 65_536)) } }) - const exitSubscription = pty.onExit(({ exitCode, signal }) => { - dataSubscription.dispose() - exitSubscription.dispose() - this.clearTimeouts(run) - this.runs.delete(runId) - resolveExit({ exitCode, signal, cancelled: run.cancelled }) + exitSubscription = pty.onExit(({ exitCode, signal }) => { + run.finish(exitCode, signal) }) if (this.runs.get(runId) === run) { - run.timeout = setTimeout(() => this.terminate(runId, run), AUTH_RUN_TIMEOUT_MS) + run.timeout = setTimeout(() => this.terminate(runId, run, 'timed_out'), AUTH_RUN_TIMEOUT_MS) run.timeout.unref?.() } return { runId, completion } @@ -81,7 +108,7 @@ export class AcpTerminalAuthRunner { if (run.ownerWebContentsId !== ownerWebContentsId) { throw new Error('ACP authentication terminal belongs to another renderer') } - this.terminate(runId, run) + this.terminate(runId, run, 'cancelled') return true } @@ -95,33 +122,48 @@ export class AcpTerminalAuthRunner { shutdown(): void { for (const [runId, run] of this.runs) { - this.terminate(runId, run) + this.terminate(runId, run, 'cancelled') } } - private terminate(runId: string, run: AcpTerminalAuthRun): void { - run.cancelled = true + private terminate( + runId: string, + run: AcpTerminalAuthRun, + reason: Exclude + ): void { + if (this.runs.get(runId) !== run || run.terminationReason) return + run.terminationReason = reason + if (run.timeout) { + clearTimeout(run.timeout) + run.timeout = null + } try { run.pty.kill() } catch {} - if (this.platform === 'win32' || this.runs.get(runId) !== run || run.forceKillTimeout) { - return + if (this.platform !== 'win32') { + run.forceKillTimeout = setTimeout(() => { + run.forceKillTimeout = null + if (this.runs.get(runId) !== run) return + try { + run.pty.kill('SIGKILL') + } catch {} + }, AUTH_KILL_GRACE_MS) + run.forceKillTimeout.unref?.() } - run.forceKillTimeout = setTimeout(() => { - run.forceKillTimeout = null - if (this.runs.get(runId) !== run) return - try { - run.pty.kill('SIGKILL') - } catch {} - }, AUTH_KILL_GRACE_MS) - run.forceKillTimeout.unref?.() + run.exitFallbackTimeout = setTimeout(() => { + run.exitFallbackTimeout = null + run.finish(-1) + }, AUTH_EXIT_FALLBACK_MS) + run.exitFallbackTimeout.unref?.() } private clearTimeouts(run: AcpTerminalAuthRun): void { if (run.timeout) clearTimeout(run.timeout) if (run.forceKillTimeout) clearTimeout(run.forceKillTimeout) + if (run.exitFallbackTimeout) clearTimeout(run.exitFallbackTimeout) run.timeout = null run.forceKillTimeout = null + run.exitFallbackTimeout = null } private requireOwnedRun(runId: string, ownerWebContentsId: number): AcpTerminalAuthRun { diff --git a/src/main/agent/acp/routes.ts b/src/main/agent/acp/routes.ts index 6c4fd8320c..8338132d36 100644 --- a/src/main/agent/acp/routes.ts +++ b/src/main/agent/acp/routes.ts @@ -13,10 +13,14 @@ export function createAcpRoutes(dependencies: { auth: AcpAuthService }) { [ acpAuthInspectRoute.name, async (rawInput, context) => { - requireRendererCaller(context) + const caller = requireRendererCaller(context) const input = acpAuthInspectRoute.input.parse(rawInput) return acpAuthInspectRoute.output.parse({ - challenge: await dependencies.auth.inspect(input.agentId, input.workdir) + challenge: await dependencies.auth.inspect( + input.agentId, + input.workdir, + caller.webContentsId + ) }) } ], diff --git a/src/main/agent/acp/runtime/acpProcessManager.ts b/src/main/agent/acp/runtime/acpProcessManager.ts index 6b79c3f4ac..1807bc4446 100644 --- a/src/main/agent/acp/runtime/acpProcessManager.ts +++ b/src/main/agent/acp/runtime/acpProcessManager.ts @@ -225,6 +225,7 @@ const createLaunchSignature = ( }) const AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000 +const AUTH_AGENT_TIMEOUT_MS = 10 * 60 * 1000 export class AcpProcessManager implements AgentProcessManager { private readonly publishEvent: DeepChatEventPublisher @@ -267,9 +268,11 @@ export class AcpProcessManager implements AgentProcessManager>() private readonly authChallenges = new Map() private readonly activeAuthScopes = new Map() + private readonly reservedAuthHandles = new Map() private readonly initializingChildren = new Set() private readonly terminatedChildren = new WeakSet() private readonly disposedHandles = new WeakSet() + private readonly shutdownController = new AbortController() private shuttingDown = false private shutdownPromise?: Promise @@ -695,12 +698,80 @@ export class AcpProcessManager implements AgentProcessManager { + async authenticateAgent( + challengeId: string, + methodId: string, + signal?: AbortSignal + ): Promise { const challenge = await this.claimAuthChallenge(challengeId, methodId, 'agent') + let timeout: ReturnType | null = null + const cleanupListeners: Array<() => void> = [] + let interrupted = false try { - await challenge.handle.connection.authenticate({ methodId }) + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + interrupted = true + reject(new Error(`ACP authentication timed out after ${AUTH_AGENT_TIMEOUT_MS}ms`)) + }, AUTH_AGENT_TIMEOUT_MS) + timeout.unref?.() + }) + const connectionClosedPromise = challenge.handle.connection.closed.then(() => { + interrupted = true + throw new Error('ACP connection closed during authentication') + }) + const processExitPromise = new Promise((_resolve, reject) => { + const onExit = (code: number | null, processSignal: NodeJS.Signals | null) => { + interrupted = true + reject( + new Error( + `ACP agent exited during authentication (code=${code ?? 'null'}, signal=${processSignal ?? 'null'})` + ) + ) + } + challenge.handle.child.once('exit', onExit) + cleanupListeners.push(() => challenge.handle.child.removeListener('exit', onExit)) + if (!this.isHandleAlive(challenge.handle)) { + onExit(challenge.handle.child.exitCode, challenge.handle.child.signalCode) + } + }) + const abortPromise = new Promise((_resolve, reject) => { + const signals = [signal, this.shutdownController.signal].filter( + (candidate): candidate is AbortSignal => Boolean(candidate) + ) + for (const candidate of signals) { + const onAbort = () => { + interrupted = true + reject( + candidate.reason instanceof Error + ? candidate.reason + : new Error('ACP authentication was cancelled') + ) + } + if (candidate.aborted) { + onAbort() + return + } + candidate.addEventListener('abort', onAbort, { once: true }) + cleanupListeners.push(() => candidate.removeEventListener('abort', onAbort)) + } + }) + await Promise.race([ + challenge.handle.connection.authenticate({ methodId }), + timeoutPromise, + connectionClosedPromise, + processExitPromise, + abortPromise + ]) challenge.consumed = true + } catch (error) { + if (interrupted) { + challenge.consumed = true + await this.disposeHandle(challenge.handle) + } + throw error } finally { + if (timeout) clearTimeout(timeout) + cleanupListeners.forEach((cleanup) => cleanup()) this.releaseAuthChallenge(challenge) } } @@ -735,9 +806,16 @@ export class AcpProcessManager implements AgentProcessManager { if (this.shutdownPromise) return this.shutdownPromise this.shuttingDown = true + this.shutdownController.abort(new Error('ACP process manager is shutting down')) const handles = this.listProcesses() const allAgents = new Set() const handleCleanup = handles.map((handle) => { @@ -865,6 +951,7 @@ export class AcpProcessManager implements AgentProcessManager { await Promise.allSettled(handleCleanup) @@ -881,6 +968,7 @@ export class AcpProcessManager implements AgentProcessManager handle.agentId === agentId && handle.state === 'warmup' && + !this.reservedAuthHandles.has(handle) && (!workdir || !handle.workdir || handle.workdir === resolvedWorkdir) ) const handle = @@ -2222,7 +2310,10 @@ export class AcpProcessManager implements AgentProcessManager - handle.workdir === workdir && handle.state === 'warmup' && this.isHandleAlive(handle) + handle.workdir === workdir && + handle.state === 'warmup' && + !this.reservedAuthHandles.has(handle) && + this.isHandleAlive(handle) ) return candidates[0] } diff --git a/src/renderer/src/components/acp/AcpAuthDialog.vue b/src/renderer/src/components/acp/AcpAuthDialog.vue index 68d38fdb77..5f71bef85b 100644 --- a/src/renderer/src/components/acp/AcpAuthDialog.vue +++ b/src/renderer/src/components/acp/AcpAuthDialog.vue @@ -113,6 +113,7 @@ let terminalInput = '' let terminalInputTimer: ReturnType | null = null let emittedSuccess = false let authenticationAttempt = 0 +let latestStateVersion = 0 const selectedMethod = computed(() => props.challenge?.methods.find((method) => method.id === selectedMethodId.value) @@ -138,6 +139,7 @@ function resetDialog() { runId.value = null error.value = null emittedSuccess = false + latestStateVersion = 0 const supported = props.challenge?.methods.filter((method) => method.type !== 'unsupported') ?? [] selectedMethodId.value = supported.length === 1 ? supported[0].id : '' terminal?.dispose() @@ -175,6 +177,7 @@ async function ensureTerminal() { async function startAuthentication() { if (!props.challenge || !selectedMethod.value) return const attempt = ++authenticationAttempt + const stateVersionAtStart = latestStateVersion error.value = null state.value = 'running' try { @@ -183,13 +186,15 @@ async function startAuthentication() { if (result.runId) cancelRun(result.runId) return } + if (result.version <= latestStateVersion) return + latestStateVersion = result.version state.value = result.state runId.value = result.runId ?? null error.value = result.error ?? null if (runId.value) await ensureTerminal() notifySucceeded() } catch (caught) { - if (attempt !== authenticationAttempt) return + if (attempt !== authenticationAttempt || latestStateVersion !== stateVersionAtStart) return state.value = 'failed' error.value = caught instanceof Error ? caught.message : String(caught) } @@ -209,9 +214,16 @@ function invalidateAuthenticationAttempt() { authenticationAttempt += 1 const activeRunId = authPending.value ? runId.value : null runId.value = null + clearTerminalInput() if (activeRunId) cancelRun(activeRunId) } +function clearTerminalInput() { + if (terminalInputTimer) clearTimeout(terminalInputTimer) + terminalInputTimer = null + terminalInput = '' +} + function cancelRun(activeRunId: string) { void client.cancel(activeRunId).catch(() => {}) } @@ -233,6 +245,8 @@ const stopState = client.onStateChanged((payload) => { if (!props.open) return if (payload.challengeId !== props.challenge?.id) return if (runId.value && payload.runId && payload.runId !== runId.value) return + if (payload.version <= latestStateVersion) return + latestStateVersion = payload.version runId.value = payload.runId ?? runId.value state.value = payload.state error.value = payload.error ?? null @@ -251,7 +265,6 @@ onBeforeUnmount(() => { invalidateAuthenticationAttempt() stopOutput() stopState() - if (terminalInputTimer) clearTimeout(terminalInputTimer) terminal?.dispose() }) diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index e9cbcc51b1..3f762f83c9 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -1762,22 +1762,22 @@ "noAgent": "Vælg en agent for at administrere dens profiler." }, "auth": { - "title": "Sign in to {name}", - "description": "Choose an authentication method. Credentials stay with the agent process.", - "requiredTitle": "{name} needs sign-in", - "requiredDescription": "Authenticate before starting this chat.", - "checkSignIn": "Check sign-in", - "openTerminal": "Open terminal", - "cancelSignIn": "Cancel sign-in", - "unsupported": "This authentication method is not supported. Configure its environment variables manually.", - "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "title": "Log ind på {name}", + "description": "Vælg en godkendelsesmetode. Legitimationsoplysningerne forbliver hos agentprocessen.", + "requiredTitle": "{name} kræver login", + "requiredDescription": "Godkend, før du starter denne chat.", + "checkSignIn": "Kontrollér login", + "openTerminal": "Åbn terminal", + "cancelSignIn": "Annuller login", + "unsupported": "Denne godkendelsesmetode understøttes ikke. Konfigurer dens miljøvariabler manuelt.", + "noMethods": "Agenten angav ikke en godkendelsesmetode, som DeepChat kan bruge.", "status": { - "required": "Authentication required", - "running": "Signing in", - "reconnecting": "Reconnecting", - "succeeded": "Ready", - "cancelled": "Cancelled", - "failed": "Failed" + "required": "Godkendelse påkrævet", + "running": "Logger ind", + "reconnecting": "Opretter forbindelse igen", + "succeeded": "Klar", + "cancelled": "Annulleret", + "failed": "Mislykket" } }, "terminal": { diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 40c16111cd..d01d05c0c5 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -1829,22 +1829,22 @@ "noAgent": "בחר סוכן כדי לנהל את הפרופילים שלו." }, "auth": { - "title": "Sign in to {name}", - "description": "Choose an authentication method. Credentials stay with the agent process.", - "requiredTitle": "{name} needs sign-in", - "requiredDescription": "Authenticate before starting this chat.", - "checkSignIn": "Check sign-in", - "openTerminal": "Open terminal", - "cancelSignIn": "Cancel sign-in", - "unsupported": "This authentication method is not supported. Configure its environment variables manually.", - "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "title": "כניסה אל {name}", + "description": "בחר שיטת אימות. פרטי הגישה נשארים בתהליך הסוכן.", + "requiredTitle": "{name} דורש כניסה", + "requiredDescription": "יש לבצע אימות לפני תחילת הצ׳אט.", + "checkSignIn": "בדיקת כניסה", + "openTerminal": "פתיחת מסוף", + "cancelSignIn": "ביטול כניסה", + "unsupported": "שיטת אימות זו אינה נתמכת. יש להגדיר את משתני הסביבה שלה באופן ידני.", + "noMethods": "הסוכן לא סיפק שיטת אימות שבה DeepChat יכול להשתמש.", "status": { - "required": "Authentication required", - "running": "Signing in", - "reconnecting": "Reconnecting", - "succeeded": "Ready", - "cancelled": "Cancelled", - "failed": "Failed" + "required": "נדרש אימות", + "running": "מתבצעת כניסה", + "reconnecting": "מתבצע חיבור מחדש", + "succeeded": "מוכן", + "cancelled": "בוטל", + "failed": "נכשל" } }, "terminal": { diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 59ba61be32..f23ed23514 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -1855,22 +1855,22 @@ "initializeSuccess": "Инициализация началась", "initializing": "Инициализация...", "auth": { - "title": "Sign in to {name}", - "description": "Choose an authentication method. Credentials stay with the agent process.", - "requiredTitle": "{name} needs sign-in", - "requiredDescription": "Authenticate before starting this chat.", - "checkSignIn": "Check sign-in", - "openTerminal": "Open terminal", - "cancelSignIn": "Cancel sign-in", - "unsupported": "This authentication method is not supported. Configure its environment variables manually.", - "noMethods": "The agent did not provide an authentication method that DeepChat can use.", + "title": "Войти в {name}", + "description": "Выберите способ аутентификации. Учетные данные остаются в процессе агента.", + "requiredTitle": "{name}: требуется вход", + "requiredDescription": "Пройдите аутентификацию перед началом чата.", + "checkSignIn": "Проверить вход", + "openTerminal": "Открыть терминал", + "cancelSignIn": "Отменить вход", + "unsupported": "Этот способ аутентификации не поддерживается. Настройте необходимые переменные окружения вручную.", + "noMethods": "Агент не предоставил способ аутентификации, который может использовать DeepChat.", "status": { - "required": "Authentication required", - "running": "Signing in", - "reconnecting": "Reconnecting", - "succeeded": "Ready", - "cancelled": "Cancelled", - "failed": "Failed" + "required": "Требуется аутентификация", + "running": "Выполняется вход", + "reconnecting": "Повторное подключение", + "succeeded": "Готово", + "cancelled": "Отменено", + "failed": "Ошибка" } }, "terminal": { diff --git a/src/shared/contracts/routes/acp-auth.routes.ts b/src/shared/contracts/routes/acp-auth.routes.ts index 8e2eff81d9..dd030d59a7 100644 --- a/src/shared/contracts/routes/acp-auth.routes.ts +++ b/src/shared/contracts/routes/acp-auth.routes.ts @@ -31,7 +31,8 @@ export const AcpAuthRunStatusSchema = z.object({ challengeId: z.string().min(1), runId: z.string().min(1).optional(), state: AcpAuthRunStateSchema, - error: z.string().optional() + error: z.string().optional(), + version: z.number().int().nonnegative() }) export const acpAuthInspectRoute = defineRouteContract({ diff --git a/src/shared/types/acp.ts b/src/shared/types/acp.ts index 6c3d37434c..790c3ee527 100644 --- a/src/shared/types/acp.ts +++ b/src/shared/types/acp.ts @@ -259,6 +259,7 @@ export interface AcpAuthRunStatus { runId?: string state: AcpAuthRunState error?: string + version: number } export interface AcpSessionEntity { diff --git a/test/main/agent/acp/auth/acpAuthService.test.ts b/test/main/agent/acp/auth/acpAuthService.test.ts index e924f9e6e8..ca17f7c868 100644 --- a/test/main/agent/acp/auth/acpAuthService.test.ts +++ b/test/main/agent/acp/auth/acpAuthService.test.ts @@ -27,6 +27,7 @@ const terminalChallenge: AcpAuthChallenge = { function createHarness(challenge: AcpAuthChallenge = terminalChallenge) { const processManager = { + inspectAuthentication: vi.fn().mockResolvedValue(challenge), getAuthChallenge: vi.fn((challengeId: string) => ({ ...challenge, id: challengeId })), authenticateAgent: vi.fn().mockResolvedValue(undefined), prepareTerminalAuthentication: vi.fn().mockResolvedValue({ @@ -51,7 +52,11 @@ function createHarness(challenge: AcpAuthChallenge = terminalChallenge) { owner: { getOrCreate: () => ({ processManager }) } as never, - agentSettings: { getAcpAgents: vi.fn().mockResolvedValue([]) }, + agentSettings: { + getAcpAgents: vi + .fn() + .mockResolvedValue([{ id: 'agent-1', name: 'Agent One', command: 'agent' }]) + }, sendToRenderer, onRendererDestroyed }) @@ -162,7 +167,8 @@ describe('AcpAuthService', () => { }) expect(harness.processManager.authenticateAgent).toHaveBeenCalledWith( 'challenge-1', - 'browser-login' + 'browser-login', + expect.any(AbortSignal) ) expect(ptyMock.spawn).not.toHaveBeenCalled() }) @@ -195,7 +201,7 @@ describe('AcpAuthService', () => { await harness.service.start(`challenge-${index}`, 'browser-login', 42) } - expect(harness.service.getStatus('challenge-0', 42)).toEqual({ + expect(harness.service.getStatus('challenge-0', 42)).toMatchObject({ challengeId: 'challenge-0', state: 'required' }) @@ -215,4 +221,34 @@ describe('AcpAuthService', () => { ) expect(harness.sendToRenderer).toHaveBeenCalledTimes(eventCount) }) + + it('binds inspected challenges to the renderer that received them', async () => { + const harness = createHarness() + + await harness.service.inspect('agent-1', undefined, 42) + + await expect(harness.service.start('challenge-1', 'terminal-login', 99)).rejects.toThrow( + 'owned by another renderer' + ) + expect(harness.processManager.prepareTerminalAuthentication).not.toHaveBeenCalled() + }) + + it('reports terminal timeouts as failures even when the PTY never exits', async () => { + vi.useFakeTimers() + const harness = createHarness() + try { + await harness.service.start('challenge-1', 'terminal-login', 42) + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 4 * 1000) + + expect(harness.service.getStatus('challenge-1', 42)).toMatchObject({ + state: 'failed', + error: 'Authentication process timed out' + }) + expect(harness.processManager.completeTerminalAuthentication).not.toHaveBeenCalled() + } finally { + harness.service.shutdown() + vi.useRealTimers() + } + }) }) diff --git a/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts b/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts index 6ceffd3ed4..f7ab8f33b3 100644 --- a/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts +++ b/test/main/agent/acp/auth/acpTerminalAuthRunner.test.ts @@ -25,7 +25,7 @@ describe('AcpTerminalAuthRunner', () => { const exit = await started.completion - expect(exit).toMatchObject({ exitCode: 0, cancelled: false }) + expect(exit).toMatchObject({ exitCode: 0, reason: 'exited' }) expect(output.join('')).toContain('PTY_AUTH_OK') } finally { if (previousSentinel === undefined) delete process.env[sentinelName] diff --git a/test/main/agent/acp/auth/acpTerminalAuthRunnerLifecycle.test.ts b/test/main/agent/acp/auth/acpTerminalAuthRunnerLifecycle.test.ts index a703624b57..f61dc2cc24 100644 --- a/test/main/agent/acp/auth/acpTerminalAuthRunnerLifecycle.test.ts +++ b/test/main/agent/acp/auth/acpTerminalAuthRunnerLifecycle.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const ptyMock = vi.hoisted(() => ({ spawn: vi.fn(), kill: vi.fn(), + dataListener: null as ((data: string) => void) | null, exitListener: null as ((event: { exitCode: number; signal?: number }) => void) | null })) @@ -24,11 +25,15 @@ describe('AcpTerminalAuthRunner lifecycle', () => { beforeEach(() => { vi.useFakeTimers() ptyMock.kill.mockReset() + ptyMock.dataListener = null ptyMock.exitListener = null ptyMock.spawn.mockReset().mockReturnValue({ kill: ptyMock.kill, write: vi.fn(), - onData: vi.fn(() => ({ dispose: vi.fn() })), + onData: vi.fn((listener) => { + ptyMock.dataListener = listener + return { dispose: vi.fn() } + }), onExit: vi.fn((listener) => { ptyMock.exitListener = listener return { dispose: vi.fn() } @@ -63,19 +68,42 @@ describe('AcpTerminalAuthRunner lifecycle', () => { runner.cancel(started.runId, 7) ptyMock.exitListener?.({ exitCode: 0 }) - await expect(started.completion).resolves.toMatchObject({ cancelled: true }) + await expect(started.completion).resolves.toMatchObject({ reason: 'cancelled' }) vi.advanceTimersByTime(2_000) expect(ptyMock.kill).toHaveBeenCalledOnce() }) - it('cancels a terminal authentication run after ten minutes', async () => { + it('times out a terminal authentication run after ten minutes', async () => { const { started } = startRunner('darwin') vi.advanceTimersByTime(10 * 60 * 1000) expect(ptyMock.kill).toHaveBeenCalledWith() ptyMock.exitListener?.({ exitCode: 0 }) - await expect(started.completion).resolves.toMatchObject({ cancelled: true }) + await expect(started.completion).resolves.toMatchObject({ reason: 'timed_out' }) + }) + + it('settles cancellation when a killed PTY never reports exit', async () => { + const { runner, started } = startRunner('darwin') + + runner.cancel(started.runId, 7) + await vi.advanceTimersByTimeAsync(4_000) + + await expect(started.completion).resolves.toMatchObject({ + exitCode: -1, + reason: 'cancelled' + }) + expect(() => runner.write(started.runId, 7, 'late input')).toThrow('is not running') + }) + + it('terminates a run that exceeds its aggregate output limit', async () => { + const { started } = startRunner('darwin') + + ptyMock.dataListener?.('x'.repeat(1024 * 1024 + 1)) + expect(ptyMock.kill).toHaveBeenCalledWith() + await vi.advanceTimersByTimeAsync(4_000) + + await expect(started.completion).resolves.toMatchObject({ reason: 'output_limit' }) }) }) diff --git a/test/main/agent/acp/runtime/acpProcessManager.test.ts b/test/main/agent/acp/runtime/acpProcessManager.test.ts index 3d533f03eb..a98d9a140a 100644 --- a/test/main/agent/acp/runtime/acpProcessManager.test.ts +++ b/test/main/agent/acp/runtime/acpProcessManager.test.ts @@ -261,6 +261,70 @@ describe('AcpProcessManager config cache fallback', () => { await expect(firstStart).resolves.toMatchObject({ challenge: { id: challenge.id } }) }) + it('does not bind a warmup handle while authentication owns it', async () => { + const manager = createManager() + const handle = createProcessHandle(new MockSpawnedChild()) + handle.authMethods = [ + { id: 'browser-login', name: 'Browser login', type: 'terminal', args: ['auth'] } + ] + ;(manager as any).handles.set('agent-1::/tmp/workspace', handle) + const challenge = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + + await manager.prepareTerminalAuthentication(challenge.id, 'browser-login') + manager.bindProcess('agent-1', 'conv-1') + + expect(handle.state).toBe('warmup') + expect(manager.getBoundProcess('conv-1')).toBeNull() + + manager.abandonAuthentication(challenge.id) + manager.bindProcess('agent-1', 'conv-1') + expect(manager.getBoundProcess('conv-1')).toBe(handle) + }) + + it('consumes a terminal challenge before reconnecting', async () => { + const manager = createManager() + const handle = createProcessHandle(new MockSpawnedChild()) + handle.authMethods = [ + { id: 'browser-login', name: 'Browser login', type: 'terminal', args: ['auth'] } + ] + const challenge = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + await manager.prepareTerminalAuthentication(challenge.id, 'browser-login') + vi.spyOn(manager, 'getConnection').mockRejectedValue(new Error('reconnect failed')) + + await expect(manager.completeTerminalAuthentication(challenge.id)).rejects.toThrow( + 'reconnect failed' + ) + + expect(() => manager.getAuthChallenge(challenge.id)).toThrow('unavailable or expired') + expect((manager as any).activeAuthScopes.size).toBe(0) + }) + + it('bounds agent-owned authentication when the ACP request never settles', async () => { + vi.useFakeTimers() + const manager = createManager() + const child = new MockSpawnedChild() + const handle = createProcessHandle(child) + handle.authMethods = [{ id: 'oauth', name: 'OAuth' }] + handle.connection = { + authenticate: vi.fn(() => new Promise(() => {})), + closed: new Promise(() => {}) + } as any + const challenge = manager.createAuthChallenge(handle as any, { origin: 'settings_probe' }) + try { + const authentication = manager.authenticateAgent(challenge.id, 'oauth') + const rejection = expect(authentication).rejects.toThrow('timed out') + + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + await rejection + + expect(child.kill).toHaveBeenCalledOnce() + expect((manager as any).activeAuthScopes.size).toBe(0) + expect(() => manager.getAuthChallenge(challenge.id)).toThrow('unavailable or expired') + } finally { + vi.useRealTimers() + } + }) + it('does not expose terminal methods as supported when the capability is disabled', () => { const manager = new AcpProcessManager({ publishEvent: publishDeepchatEventMock, diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 53366e5e16..3a7b03062d 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -1742,10 +1742,12 @@ function createRuntime() { methods: [], origin: 'settings_probe' }), - start: vi.fn().mockResolvedValue({ challengeId: 'challenge-1', state: 'running' }), + start: vi.fn().mockResolvedValue({ challengeId: 'challenge-1', state: 'running', version: 1 }), write: vi.fn(), cancel: vi.fn().mockReturnValue(true), - getStatus: vi.fn().mockReturnValue({ challengeId: 'challenge-1', state: 'required' }) + getStatus: vi + .fn() + .mockReturnValue({ challengeId: 'challenge-1', state: 'required', version: 1 }) } const acpRoutes = createAcpRoutes({ auth: acpAuth as never }) const deviceRoutes = createDeviceRoutes({ @@ -3225,6 +3227,7 @@ describe('dispatchDeepchatRoute', () => { const { runtime, acpAuth } = createRuntime() const context = createRendererRouteContext(42, 7) + await dispatchDeepchatRoute(runtime, 'acpAuth.inspect', { agentId: 'agent-1' }, context) const inputResult = await dispatchDeepchatRoute( runtime, 'acpAuth.input', @@ -3238,6 +3241,7 @@ describe('dispatchDeepchatRoute', () => { context ) + expect(acpAuth.inspect).toHaveBeenCalledWith('agent-1', undefined, 42) expect(acpAuth.write).toHaveBeenCalledWith('run-1', 42, 'hello\n') expect(acpAuth.cancel).toHaveBeenCalledWith('run-1', 42) expect(inputResult).toEqual({ sent: true }) diff --git a/test/renderer/api/clients.test.ts b/test/renderer/api/clients.test.ts index 24bd6150fe..1b091cd8eb 100644 --- a/test/renderer/api/clients.test.ts +++ b/test/renderer/api/clients.test.ts @@ -49,7 +49,12 @@ describe('renderer api clients', () => { } case 'acpAuth.start': case 'acpAuth.status': - return { challengeId: 'challenge-1', runId: 'run-1', state: 'running' } + return { + challengeId: 'challenge-1', + runId: 'run-1', + state: 'running', + version: 1 + } case 'acpAuth.input': return { sent: true } case 'acpAuth.cancel': diff --git a/test/renderer/components/AcpAuthDialog.test.ts b/test/renderer/components/AcpAuthDialog.test.ts index 09d5cc495d..02afdb34f6 100644 --- a/test/renderer/components/AcpAuthDialog.test.ts +++ b/test/renderer/components/AcpAuthDialog.test.ts @@ -11,11 +11,14 @@ const authClient = vi.hoisted(() => ({ stateListener: null as ((payload: unknown) => void) | null })) const terminalWrite = vi.hoisted(() => vi.fn()) +const terminalData = vi.hoisted(() => ({ listener: null as ((data: string) => void) | null })) vi.mock('@xterm/xterm', () => ({ Terminal: class { open() {} - onData() {} + onData(listener: (data: string) => void) { + terminalData.listener = listener + } write = terminalWrite dispose() {} } @@ -82,6 +85,7 @@ beforeEach(() => { authClient.outputListener = null authClient.stateListener = null terminalWrite.mockReset() + terminalData.listener = null }) describe('AcpAuthDialog', () => { @@ -108,7 +112,11 @@ describe('AcpAuthDialog', () => { }) it('emits success only after the selected method succeeds', async () => { - authClient.start.mockResolvedValue({ challengeId: 'challenge-1', state: 'succeeded' }) + authClient.start.mockResolvedValue({ + challengeId: 'challenge-1', + state: 'succeeded', + version: 1 + }) const wrapper = await mountDialog( baseChallenge([{ id: 'agent', name: 'Agent login', type: 'agent' }]) ) @@ -132,7 +140,7 @@ describe('AcpAuthDialog', () => { it('keeps terminal output that arrives before the start response', async () => { let resolveStart: - | ((value: { challengeId: string; runId: string; state: 'running' }) => void) + | ((value: { challengeId: string; runId: string; state: 'running'; version: number }) => void) | null = null authClient.start.mockReturnValue( new Promise((resolve) => { @@ -155,13 +163,18 @@ describe('AcpAuthDialog', () => { expect((wrapper.vm as any).runId).toBe('run-early') expect(terminalWrite).toHaveBeenCalledWith('EARLY_OUTPUT') - resolveStart?.({ challengeId: 'challenge-1', runId: 'run-early', state: 'running' }) + resolveStart?.({ + challengeId: 'challenge-1', + runId: 'run-early', + state: 'running', + version: 1 + }) await starting }) it('cancels a terminal run returned after the dialog closes', async () => { let resolveStart: - | ((value: { challengeId: string; runId: string; state: 'running' }) => void) + | ((value: { challengeId: string; runId: string; state: 'running'; version: number }) => void) | null = null authClient.start.mockReturnValue( new Promise((resolve) => { @@ -174,7 +187,12 @@ describe('AcpAuthDialog', () => { const starting = (wrapper.vm as any).startAuthentication() ;(wrapper.vm as any).handleOpenChange(false) - resolveStart?.({ challengeId: 'challenge-1', runId: 'run-late', state: 'running' }) + resolveStart?.({ + challengeId: 'challenge-1', + runId: 'run-late', + state: 'running', + version: 1 + }) await starting expect(authClient.cancel).toHaveBeenCalledWith('run-late') @@ -185,7 +203,8 @@ describe('AcpAuthDialog', () => { authClient.start.mockResolvedValue({ challengeId: 'challenge-1', runId: 'run-active', - state: 'running' + state: 'running', + version: 1 }) const wrapper = await mountDialog( baseChallenge([{ id: 'browser', name: 'Browser login', type: 'terminal' }]) @@ -196,4 +215,72 @@ describe('AcpAuthDialog', () => { expect(authClient.cancel).toHaveBeenCalledWith('run-active') }) + + it('does not apply a start response older than a state event', async () => { + let resolveStart: + | ((value: { challengeId: string; runId: string; state: 'running'; version: number }) => void) + | null = null + authClient.start.mockReturnValue( + new Promise((resolve) => { + resolveStart = resolve + }) + ) + const wrapper = await mountDialog( + baseChallenge([{ id: 'browser', name: 'Browser login', type: 'terminal' }]) + ) + + const starting = (wrapper.vm as any).startAuthentication() + authClient.stateListener?.({ + challengeId: 'challenge-1', + runId: 'run-1', + state: 'succeeded', + version: 2 + }) + resolveStart?.({ + challengeId: 'challenge-1', + runId: 'run-1', + state: 'running', + version: 1 + }) + await starting + + expect((wrapper.vm as any).state).toBe('succeeded') + expect(wrapper.emitted('succeeded')).toHaveLength(1) + }) + + it('drops buffered terminal input before a later run starts', async () => { + vi.useFakeTimers() + authClient.start + .mockResolvedValueOnce({ + challengeId: 'challenge-1', + runId: 'run-1', + state: 'running', + version: 1 + }) + .mockResolvedValueOnce({ + challengeId: 'challenge-1', + runId: 'run-2', + state: 'running', + version: 2 + }) + const wrapper = await mountDialog( + baseChallenge([{ id: 'browser', name: 'Browser login', type: 'terminal' }]) + ) + try { + await (wrapper.vm as any).startAuthentication() + terminalData.listener?.('secret') + + ;(wrapper.vm as any).handleOpenChange(false) + await wrapper.setProps({ open: false }) + await wrapper.setProps({ open: true }) + await flushPromises() + await (wrapper.vm as any).startAuthentication() + await vi.advanceTimersByTimeAsync(8) + + expect(authClient.sendInput).not.toHaveBeenCalled() + } finally { + wrapper.unmount() + vi.useRealTimers() + } + }) })