diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f8962cf5e..b257ae0fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,7 +47,7 @@ flowchart LR | `LLMProviderPresenter` | `src/main/presenter/llmProviderPresenter/` | provider 实例、model/runtime 管理、ACP helper、AI SDK runtime | | `StartupWorkloadCoordinator` | `src/main/presenter/startupWorkloadCoordinator/` | startup/settings/floating 等目标的分阶段后台任务调度 | | `RemoteControlPresenter` | `src/main/presenter/remoteControlPresenter/` | Telegram、Feishu/Lark、QQBot、Discord、WeChat iLink 远程控制 | -| `ScheduledTasksService` | `src/main/presenter/scheduledTasks/` | 一次性、每日、每周任务调度和 prompt/notify action dispatch | +| `CronJobsService` | `src/main/presenter/cronJobs/` | 定时任务持久化、cron 调度、Agent run 执行和 Remote 投递 | | `DatabaseSecurityPresenter` | `src/main/presenter/databaseSecurityPresenter/` | SQLCipher 启用、改密、关闭、safeStorage/manual unlock | | Spotlight search | `src/renderer/src/stores/ui/spotlight.ts` | 全局搜索、会话/消息跳转、设置导航和非破坏性 action | diff --git a/docs/FLOWS.md b/docs/FLOWS.md index 28961bf35..e20a1a1f0 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -187,24 +187,24 @@ sequenceDiagram ```mermaid sequenceDiagram - participant UI as Settings Scheduled Tasks - participant Client as ScheduledTasksClient - participant Service as ScheduledTasksService - participant Notify as NotificationPresenter - participant Session as Session creator - - UI->>Client: list/upsert/toggle/delete/fireNow - Client->>Service: scheduledTasks.* route - Service->>Service: compute next fire time - alt notify action - Service->>Notify: showNotification - else prompt action - Service->>Session: create session, optional autoSend - end + participant UI as Settings Scheduled + participant Client as CronJobsClient + participant Service as CronJobsService + participant Utility as Scheduler utility + participant Agent as AgentSessionPresenter + participant Remote as RemoteControlPresenter + + UI->>Client: list/upsert/toggle/runNow + Client->>Service: cronJobs.* route + Service->>Utility: reconcile enabled jobs + Utility->>Service: RUN_DUE + Service->>Agent: create detached session and send task prompt + Agent-->>Service: run status and output updates + Service->>Remote: optional notification-only delivery ``` -Triggers 支持 once、daily、weekly;actions 支持 notification 和 prompt。Prompt action 可指定 -agent、provider、model、system prompt,并通过 route runtime 创建会话。 +Triggers 使用 cron 表达式。每次触发创建独立 detached session;Remote 投递只发送通知,不进入普通 +Remote 会话上下文。 ## 9. Remote Control diff --git a/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md b/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md new file mode 100644 index 000000000..c2e48a347 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-1-scheduler/spec.md @@ -0,0 +1,91 @@ +# Cron Agent Jobs Phase 1: Scheduler Process + +## User Need + +Users need a reliable Cron Jobs foundation that can discover due jobs without tying scheduling +accuracy to renderer lifetime, settings UI state, or one main-process timeout per task. + +## Goal + +Introduce the first independently mergeable slice of the Cron Jobs / Agent Jobs module: + +- A SQLite-backed `cron_jobs` and `cron_job_runs` store. +- A main-process `SchedulerProcessManager`. +- An Electron `utilityProcess` named `deepchat-scheduler`. +- A typed scheduler protocol where the utility process only discovers due jobs and queues runs. +- A minimal Cron Jobs status UI that shows scheduler state. + +The main process owns job execution. In this phase execution remains a mock action after receiving +`RUN_DUE`; no agent runtime work is performed yet. + +## Current State + +The removed legacy scheduled-task service used ConfigPresenter timers. Cron Jobs is the only +scheduler surface going forward and stores schedules in SQLite. + +## Acceptance Criteria + +- With zero enabled cron jobs, `deepchat-scheduler` is stopped. +- Creating or enabling the first cron job starts `deepchat-scheduler` after the app is ready. +- Disabling or deleting the last enabled cron job stops the scheduler within 30 seconds. +- The scheduler scans only `next_run_at <= Date.now()` jobs and creates `queued` runs. +- Queued run creation is idempotent for the same `(job_id, scheduled_at)` slot. +- Main receives `RUN_DUE` with `jobId` and `runId`. +- App startup and OS resume trigger `RECONCILE`. +- Utility-process crash is detected and restarted with bounded backoff while enabled jobs remain. +- Scheduler status is available through a typed route and rendered in the Cron Jobs page. +- The Cron Jobs page renders schedule-derived next runs as a read-only indicator, not a + user-editable schedule setting. + +## UX Shape + +Running state: + +```text ++---------------------------------------------------------+ +| Cron Jobs | +| Scheduler: Running | utilityProcess | pid 18421 | +| Enabled jobs: 2 | Next run: 2026-07-03 09:00 | +| | +| [New Job] [Restart Timer] | ++---------------------------------------------------------+ +``` + +Stopped state: + +```text ++---------------------------------------------------------+ +| Cron Jobs | +| Scheduler: Stopped | +| Create or enable a cron job to start the scheduler. | +| | +| [New Job] | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No cron expression parser. +- No schedule editor beyond minimal data needed to exercise scheduler state. +- No agent binding, permission resolution, or real session execution. +- No delivery, remote continuation, or `cronjob` agent tool. +- Legacy scheduled-task cleanup is handled by `docs/issues/remove-legacy-scheduled-tasks`. + +## Constraints + +- Use typed routes, typed contracts, and renderer `api/*Client`. +- Do not introduce legacy IPC or `useLegacyPresenter()` paths. +- The scheduler utility process must not execute model, tool, notification, or remote delivery logic. +- If the database is encrypted, the utility process must open its own connection through a + narrowly scoped scheduler DB adapter and must not log database credentials. +- `utilityProcess.fork()` must be invoked only after Electron app `ready`. + +## References + +- Electron `utilityProcess` creates a Node-enabled child process and supports message passing: + https://www.electronjs.org/docs/latest/api/utility-process + +## Open Questions + +None. Phase 1 deliberately keeps execution mocked so later phases can replace the mock without +reworking the scheduler lifecycle. diff --git a/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/spec.md b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/spec.md new file mode 100644 index 000000000..befb50fe4 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-2-cron-trigger-engine/spec.md @@ -0,0 +1,110 @@ +# Cron Agent Jobs Phase 2: Cron Trigger Engine + +## User Need + +Users need one expressive schedule model instead of separate once/daily/weekly trigger kinds. +The schedule must preview upcoming runs, respect timezones, and handle missed runs predictably. + +## Goal + +Replace phase 1's timestamp-only scheduling input with a cron-expression trigger engine: + +- Store every schedule as `cronExpr + timezone`. +- Validate expressions through a dedicated parser. +- Compute and persist `next_run_at`. +- Preview the next N runs in the UI and tool-facing service layer. +- Support misfire policy for missed runs. +- Provide preset controls that only generate cron expressions. + +## Parser Choice + +Use `cron-parser` unless implementation discovers a blocker in the locked package version. +The package is not currently in `package.json`, so this phase adds the dependency and tests the +exact syntax DeepChat exposes. + +The exposed syntax must be limited to parser-backed behavior verified by tests. Do not market a +cron feature in UI copy without a passing unit test for that feature. + +## Schedule Model + +```ts +type CronJobSchedule = { + cronExpr: string + timezone: string + misfirePolicy: 'skip' | 'run_once' + maxCatchUpRuns?: number +} +``` + +Presets are UI helpers only: + +```ts +type SchedulePreset = + | { type: 'every_n_minutes'; n: number } + | { type: 'hourly'; minute: number } + | { type: 'daily'; time: string } + | { type: 'weekdays'; time: string } + | { type: 'weekly'; days: number[]; time: string } + | { type: 'monthly'; day: number | 'last'; time: string } + | { type: 'custom'; cronExpr: string } +``` + +## Acceptance Criteria + +- `*/5 * * * *` previews the next 5 runs from a controlled clock. +- `0 9 * * 1-5` correctly represents weekdays at 09:00. +- `0 0 9 L * *` correctly represents the last day of each month at 09:00 when supported by the + locked parser version. +- `0 0 9 * * 1#1` correctly represents the first Monday of each month when supported by the locked + parser version. +- Timezone changes immediately update preview rows and persisted `next_run_at`. +- Invalid cron input shows a parser error and cannot enable the job. +- Scheduler due scans remain based only on `next_run_at <= now`. +- `misfirePolicy: 'skip'` advances to the next future run after downtime. +- `misfirePolicy: 'run_once'` creates at most one catch-up run per reconcile unless + `maxCatchUpRuns` is explicitly set. + +## UX Shape + +```text ++---------------------------------------------------------+ +| Schedule | +| Mode | +| (o) Preset | +| Every [day v] at [09:00] | +| | +| ( ) Cron expression | +| [0 0 9 * * * ] | +| Timezone [Asia/Tokyo v] | +| | +| Next 5 runs | +| - 2026-07-03 09:00 | +| - 2026-07-04 09:00 | +| - 2026-07-05 09:00 | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No agent binding or real execution. +- No run detail UI beyond schedule preview. +- No remote delivery or `cronjob` agent tool. +- No broad legacy task migration unless the migration can be completed without changing execution + semantics. + +## Constraints + +- Do not reintroduce once/daily/weekly as persisted model variants. +- Presets must write only `cronExpr` and `timezone`. +- Store timestamps as epoch milliseconds. +- Use deterministic tests with fixed reference dates and timezones. + +## References + +- `cron-parser` README documents timezone handling and extended syntax including `L`, `#`, and `H`: + https://github.com/harrisiirak/cron-parser/blob/master/README.md + +## Open Questions + +None. Unsupported parser syntax should be removed from UI copy instead of becoming a product +promise. diff --git a/docs/features/cron-agent-jobs-phase-3-agent-binding/spec.md b/docs/features/cron-agent-jobs-phase-3-agent-binding/spec.md new file mode 100644 index 000000000..03ead0efb --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-3-agent-binding/spec.md @@ -0,0 +1,102 @@ +# Cron Agent Jobs Phase 3: Agent Binding + +## User Need + +Users need a scheduled job to run as a specific DeepChat agent, with model, tools, MCP servers, +skills, permission mode, and workspace behavior resolved from that agent. + +## Goal + +Turn a scheduled cron job into an agent-bound job definition: + +- Every enabled job must reference a valid `agentId`. +- Runtime settings default to following the current agent config. +- Snapshot policies preserve a stable runtime when requested. +- Deleted or disabled agents make affected jobs invalid instead of silently running a fallback. + +## Job Model + +```ts +type AgentCronJob = { + id: string + name: string + description?: string + enabled: boolean + status: 'ready' | 'disabled' | 'invalid_agent' + + schedule: { + cronExpr: string + timezone: string + misfirePolicy: 'skip' | 'run_once' + } + + agent: { + agentId: string + agentVersion?: string + modelPolicy: 'follow_agent' | 'pin_current' + toolPolicy: 'follow_agent' | 'snapshot' + permissionPolicy: 'follow_agent' | 'snapshot' + } + + task: { + prompt: string + systemInstruction?: string + outputMode: 'final_message' | 'structured_json' | 'artifact' + } + + runtime: { + maxDurationMs: number + maxTurns: number + concurrencyPolicy: 'skip' | 'queue' + } + + nextRunAt: number | null +} +``` + +## Acceptance Criteria + +- Creating or enabling a job requires a valid enabled agent. +- Job execution planning resolves model/tools/MCP/skills/permissions from the bound agent. +- In `follow_agent` mode, agent config updates affect the next job run. +- In snapshot mode, the job uses the captured runtime snapshot. +- Deleting or disabling an agent moves related jobs to `invalid_agent` and prevents enqueueing. +- The job editor clearly shows which runtime parts follow the agent and which are pinned. +- The task content field grows only up to 10 text rows and scrolls internally beyond that. +- Scheduler status excludes invalid jobs from enabled runnable counts. + +## UX Shape + +```text ++---------------------------------------------------------+ +| Agent Binding | +| Agent: [DeepChat Issue Triage v] | +| | +| Runtime follows agent | +| [x] Model | +| [x] Tools / MCP / Skills | +| [x] Permission mode | +| | +| Advanced | +| [ ] Pin current model/tools/permissions | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No fresh session creation yet. +- No remote delivery. +- No `cronjob` agent tool. +- No support for running without an agent. + +## Constraints + +- Use `AgentRepository` and existing agent config normalization. +- Keep `deepchat` as an explicit agent selection, not an invisible fallback. +- Snapshot JSON must be versioned so later migrations can identify the captured shape. +- Do not expose raw provider credentials in snapshots. + +## Open Questions + +None. Snapshot support is required for model/tools/permissions at the level available through the +current agent config; unavailable runtime internals should remain follow-mode only. diff --git a/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/spec.md b/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/spec.md new file mode 100644 index 000000000..604f34499 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-4-fresh-session-runs/spec.md @@ -0,0 +1,106 @@ +# Cron Agent Jobs Phase 4: Fresh Session Runs + +## User Need + +Users need every scheduled run to produce an inspectable DeepChat session with the job prompt, +agent response, tool calls, permissions, and final output tied back to the job run. + +## Goal + +Replace phase 1's mock execution with real agent execution: + +- Each `RUN_DUE` creates a fresh session. +- The first user message is the job prompt. +- The bound agent runs through the normal DeepChat runtime. +- The run row records lifecycle status, `sessionId`, output preview, and failure details. +- Manual `Run Now` uses the same execution path. + +## Run Model + +```ts +type CronJobRun = { + id: string + jobId: string + sessionId: string | null + + scheduledAt: number + startedAt: number | null + completedAt: number | null + + status: + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'waiting_permission' + + outputMessageId?: string + outputPreview?: string + error?: string +} +``` + +## Session Metadata + +Add a maintained metadata shape for sessions created by Cron Jobs: + +```ts +type SessionMetadata = { + source: 'cron_job' + cronJobId: string + cronJobRunId: string + scheduledAt: number +} +``` + +If the current `new_sessions` table cannot store this cleanly, add a generic session metadata column +or side table rather than overloading `subagent_meta_json`. + +## Acceptance Criteria + +- Every due run creates a new session before sending the job prompt. +- `cron_job_runs.session_id` is set as soon as session creation succeeds. +- The session contains the job prompt, assistant response, and tool calls. +- Multiple runs of the same job create multiple sessions. +- Run status transitions are persisted: `queued -> running -> completed|failed|waiting_permission`. +- A failed run keeps `sessionId` if session creation already happened. +- Duplicate `RUN_DUE` events for the same run do not start duplicate sessions. +- Manual `Run Now` follows the same queued-run execution path. +- The job editor shows compact run history by timestamp. + +## UX Shape + +```text ++---------------------------------------------------------+ +| Job Run | +| Daily Issue Triage | +| Run: 2026-07-03 09:00 | completed | 2m 14s | +| Session: #cron-run-8f31 | +| | +| Result | +| - 3 new issues need triage | +| - 1 regression likely related to provider routing | +| | +| Session: created for internal run inspection | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No remote delivery. +- No inbound remote continuation. +- No `cronjob` agent tool. +- No multi-run catch-up UI beyond history list. + +## Constraints + +- Reuse `SessionService` and `ChatService`; do not create a second agent runtime path. +- Respect agent permission behavior. Jobs must not bypass user approval flows. +- Enforce `runtime.maxDurationMs`, `maxTurns`, and `concurrencyPolicy`. +- Use run-level locking to prevent duplicate starts. + +## Open Questions + +None. If a provider requires UI interaction, the run enters `waiting_permission` rather than being +silently skipped. diff --git a/docs/features/cron-agent-jobs-phase-5-delivery-continuation/spec.md b/docs/features/cron-agent-jobs-phase-5-delivery-continuation/spec.md new file mode 100644 index 000000000..6345e5317 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-5-delivery-continuation/spec.md @@ -0,0 +1,79 @@ +# Cron Agent Jobs Phase 5: Remote Delivery + +## User Need + +Users need scheduled run results to reach an enabled Remote channel where they already operate the +agent. + +## Goal + +Add Remote delivery targets: + +- Deliver run results only to enabled Remote channels with an existing binding. +- Persist one delivery receipt per target. +- Do not bind delivered messages into the normal Remote conversation context. + +## Delivery Model + +```ts +type JobDelivery = { + targets: DeliveryTarget[] + suppressSuccessNotification: boolean + notifyOnFailure: boolean +} + +type DeliveryTarget = + | { type: 'remote'; remoteId: string; channelId: string; mode: 'summary' | 'full' } +``` + +## Acceptance Criteria + +- A job can configure zero or more Remote delivery targets. +- Delivery can only be enabled when a Remote channel is enabled and has at least one binding. +- The job editor lets users select the target Remote binding. +- Every delivery attempt writes a receipt. +- Delivery failure records an error without changing the run result. +- Remote deliveries are notifications only and never continue the original session. +- Scheduled delivery messages do not become normal Remote conversation context. +- Multiple remote targets each receive independent receipts. + +## UX Shape + +```text ++---------------------------------------------------------+ +| Delivery | +| [x] Remote delivery | +| Channel: [Feishu / group:oc_xxx v] | ++---------------------------------------------------------+ +``` + +Run detail: + +```text ++---------------------------------------------------------+ +| Cron Run | +| Delivery: Feishu failed | +| | +| Delivery receipts show success or failure status | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No desktop notification, DeepChat Inbox, or origin-session delivery target in this phase. +- No new remote channel protocol. +- No inbound Remote continuation. +- No `cronjob` agent tool yet. +- No retry scheduler beyond explicit delivery retry action unless already supported by remote + infrastructure. + +## Constraints + +- Use `RemoteControlPresenter` channel boundaries; do not put channel-specific formatting in Cron + Jobs service. +- Delivery receipts must not store provider secrets. +- Output rendering must use existing remote block rendering where practical. + +## Open Questions + +None. diff --git a/docs/features/cron-agent-jobs-phase-6-cronjob-tool/spec.md b/docs/features/cron-agent-jobs-phase-6-cronjob-tool/spec.md new file mode 100644 index 000000000..8588cc368 --- /dev/null +++ b/docs/features/cron-agent-jobs-phase-6-cronjob-tool/spec.md @@ -0,0 +1,112 @@ +# Cron Agent Jobs Phase 6: cronjob Agent Tool + +## User Need + +Users need agents to create, inspect, update, pause, resume, run, and review Cron Jobs through one +tool without exposing scheduler internals or bypassing user confirmation. + +## Goal + +Add one local agent tool named `cronjob` that manages the Cron Jobs module: + +- Read actions execute directly. +- Write actions return confirmation cards before applying changes. +- The tool calls the same Cron Jobs service routes used by the UI. +- Tool results are structured and safe for model consumption. +- The tool is visible in the tool list but disabled by default for every new DeepChat agent/session. + +## Tool Input + +```ts +type CronJobToolInput = + | { action: 'create'; job: CronJobCreateInput } + | { action: 'list'; filter?: CronJobFilter } + | { action: 'show'; jobId: string } + | { action: 'update'; jobId: string; patch: CronJobPatch } + | { action: 'pause'; jobId: string } + | { action: 'resume'; jobId: string } + | { action: 'delete'; jobId: string } + | { action: 'run_now'; jobId: string } + | { action: 'history'; jobId: string; limit?: number } + | { action: 'preview_schedule'; cronExpr: string; timezone: string; count?: number } +``` + +## Create Input + +```ts +type CronJobCreateInput = { + name: string + description?: string + cronExpr: string + timezone: string + agentId: string + prompt: string + + delivery?: JobDelivery + runtime?: Partial +} +``` + +## Tool Result + +```ts +type CronJobToolResult = { + ok: boolean + job?: AgentCronJob + jobs?: AgentCronJob[] + runs?: CronJobRun[] + previewRuns?: string[] + confirmationRequired?: boolean + confirmationCard?: unknown + error?: string +} +``` + +## Acceptance Criteria + +- `preview_schedule` returns parser-backed upcoming runs. +- `list`, `show`, and `history` execute without confirmation. +- `create`, `update`, `delete`, `pause`, `resume`, and `run_now` return confirmation cards first. +- Confirming a write executes through the Cron Jobs service and returns the updated result. +- Confirmation cards show schedule, agent, prompt summary, delivery targets, and next runs. +- The tool never exposes utility-process pid, DB paths, scheduler protocol, or low-level locks. +- Tool schemas reject unknown actions and invalid payloads. +- Tool tests cover every action. +- Users must explicitly enable `cronjob` before agents can call it. + +## Confirmation Card Shape + +```text ++---------------------------------------------------------+ +| Create Cron Job | +| Name: Daily DeepChat Issue Triage | +| Schedule: 0 0 9 * * * | Asia/Tokyo | +| Agent: Issue Triage Agent | +| Delivery: Remote channel | +| Next runs: | +| - 2026-07-03 09:00 | +| - 2026-07-04 09:00 | +| - 2026-07-05 09:00 | +| | +| [Create Job] [Edit] [Cancel] | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- No second scheduling API. +- No direct utility-process controls. +- No write action without confirmation. +- No remote delivery implementation; phase 5 owns delivery. +- No automatic enablement for existing or new agents. + +## Constraints + +- Register the tool through `AgentToolManager`. +- Use existing local agent tool permission and confirmation patterns. +- Keep user-facing card strings in i18n. +- Redact long prompts and delivery payload internals in model-facing summaries. + +## Open Questions + +None. Tool write confirmation is mandatory even when the current permission mode is permissive. diff --git a/docs/features/cron-jobs-cron-expression-editor/spec.md b/docs/features/cron-jobs-cron-expression-editor/spec.md new file mode 100644 index 000000000..949cf601f --- /dev/null +++ b/docs/features/cron-jobs-cron-expression-editor/spec.md @@ -0,0 +1,49 @@ +# Cron Jobs Cron Expression Reference + +## Goal + +Keep Cron Jobs schedule editing simple by using one raw cron expression input with lightweight +reference examples below it. + +## Decision + +Do not use a visual cron picker. + +Rationale: + +- The renderless picker still expands into several dense selects and is harder to scan than cron. +- Cron Jobs already persists only `cronExpr`; keeping one input avoids duplicate schedule controls. +- Static examples cover the common schedules without adding UI state or dependencies. + +## Requirements + +- New Cron Jobs default to `* * * * *`. +- The raw cron expression input remains the only editable schedule control. +- Common examples are shown as read-only references below the input. +- Preview and validation continue to use the main-process `cronJobs.previewSchedule` and + `cronJobs.validateSchedule` routes. +- No scheduler, SQLite, route-contract, or parser changes. +- No cron editor dependency. + +## UX Shape + +```text ++---------------------------------------------------------+ +| [Name] [Agent] [Timezone] | +| Cron expression: [* * * * *] | +| */5 * * * * Every 5 minutes | +| 0 * * * * Hourly | +| 0 9 * * * Daily at 09:00 | +| 0 9 * * 1-5 Weekdays at 09:00 | +| | +| [Task prompt] [Runtime] | +| Next runs | +| [2026-07-03 09:00] [2026-07-04 09:00] ... | ++---------------------------------------------------------+ +``` + +## Non-Goals + +- Do not add clickable schedule shortcuts. +- Do not persist schedule mode, editor tabs, or UI-only state. +- Do not add an external UI framework package. diff --git a/docs/features/cron-jobs-timezone-selector/spec.md b/docs/features/cron-jobs-timezone-selector/spec.md new file mode 100644 index 000000000..64d16dac4 --- /dev/null +++ b/docs/features/cron-jobs-timezone-selector/spec.md @@ -0,0 +1,17 @@ +# Spec + +## Goal + +Cron Jobs timezone editing should use a selector instead of free text input. + +## Requirements + +- Show available IANA timezone IDs in the Cron Jobs settings editor. +- Preserve existing saved timezone values. +- Saving a selected timezone keeps the existing Cron Jobs route contract unchanged. + +## Non-Goals + +- No new timezone dependency. +- No timezone search UI in this slice. + diff --git a/docs/issues/cron-agent-jobs-concurrency-skip-delivery/spec.md b/docs/issues/cron-agent-jobs-concurrency-skip-delivery/spec.md new file mode 100644 index 000000000..94a576d6b --- /dev/null +++ b/docs/issues/cron-agent-jobs-concurrency-skip-delivery/spec.md @@ -0,0 +1,24 @@ +# Cron Agent Jobs Concurrency Skip Delivery + +## User Need + +Users should not receive Remote delivery messages for cron runs that were intentionally skipped because a previous run is still active. + +## Goal + +Suppress delivery for concurrency-policy skip cancellations while preserving the recorded cancelled run for history/debugging. + +## Acceptance Criteria + +- A skipped overlapping run does not call delivery targets. +- Genuine failed or cancelled runs still deliver when `notifyOnFailure` is enabled. +- The run remains recorded as cancelled with the existing active-run message. + +## Constraints + +- Do not add a new run status for this bug fix. +- Keep the change inside the cron run executor. + +## Open Questions + +None. diff --git a/docs/issues/cron-agent-jobs-full-remote-delivery/spec.md b/docs/issues/cron-agent-jobs-full-remote-delivery/spec.md new file mode 100644 index 000000000..d840b3581 --- /dev/null +++ b/docs/issues/cron-agent-jobs-full-remote-delivery/spec.md @@ -0,0 +1,24 @@ +# Cron Agent Jobs Full Remote Delivery + +## User Need + +Scheduled task delivery should include the same process and answer details that Remote Control sends during a normal remote conversation. + +## Goal + +Capture Remote Control delivery segments from cron-run assistant updates and use them for the delivered run output. + +## Acceptance Criteria + +- Cron run output includes ordered process and answer segments. +- Feishu scheduled task delivery is not truncated by the generic 4000-character cap. +- Existing delivery receipts continue to work. + +## Constraints + +- Do not add a new storage table. +- Do not change Remote Control streaming behavior. + +## Open Questions + +None. diff --git a/docs/issues/cron-agent-jobs-list-sticky-actions/spec.md b/docs/issues/cron-agent-jobs-list-sticky-actions/spec.md new file mode 100644 index 000000000..4d3d356c8 --- /dev/null +++ b/docs/issues/cron-agent-jobs-list-sticky-actions/spec.md @@ -0,0 +1,28 @@ +# Cron Agent Jobs List Sticky Actions + +## User Need + +The Scheduled settings page should keep the main task action reachable while users scroll through long task lists, and newly created jobs should appear after the existing jobs instead of jumping to the top. + +## Goal + +- Keep the Scheduled page header actions sticky while scrolling. +- Display cron jobs in stable creation order so new jobs are added at the bottom. + +## Acceptance Criteria + +- Clicking New Task on the cron jobs settings page appends the new job after existing jobs. +- Editing an existing job does not reorder the visible list. +- The top action area remains visible above the task list while the page scrolls. +- The sticky behavior is opt-in for the Scheduled page and does not change every settings page by default. + +## Constraints + +- Keep the implementation in the renderer settings ownership layer. +- Reuse the existing `SettingsPageShell` and page-local state. +- Do not add new user-facing strings for this layout change. + +## Non-Goals + +- Persisting a custom drag-and-drop order. +- Changing cron job scheduler behavior or IPC contracts. diff --git a/docs/issues/cron-agent-jobs-runtime-observability/spec.md b/docs/issues/cron-agent-jobs-runtime-observability/spec.md new file mode 100644 index 000000000..c14bd88b3 --- /dev/null +++ b/docs/issues/cron-agent-jobs-runtime-observability/spec.md @@ -0,0 +1,24 @@ +# Cron Agent Jobs Runtime Observability + +## User Need + +Scheduled cron runs should create agent sessions when due, and failures should be visible through run history and configured delivery targets. + +## Goal + +Ensure the cron scheduler is wired to the session starter before it starts, and avoid silent completion when no executor is available. + +## Acceptance Criteria + +- Cron jobs start after route runtime wiring has registered the run session starter. +- A due run without an executor is marked failed instead of completed. +- Failures before executor handoff are delivered when `notifyOnFailure` is enabled. + +## Constraints + +- Do not add a new cron status. +- Keep the scheduler utility process unchanged. + +## Open Questions + +None. diff --git a/docs/issues/cron-agent-jobs-stale-running-runs/spec.md b/docs/issues/cron-agent-jobs-stale-running-runs/spec.md new file mode 100644 index 000000000..534240fcf --- /dev/null +++ b/docs/issues/cron-agent-jobs-stale-running-runs/spec.md @@ -0,0 +1,24 @@ +# Cron Agent Jobs Stale Running Runs + +## User Need + +After restarting the app, stale cron run rows from a previous process must not block new runs as if another run were active. + +## Goal + +Close leftover `running` cron runs during scheduler startup before new due runs are processed. + +## Acceptance Criteria + +- Startup marks existing `running` cron runs as failed. +- New manual or scheduled runs are not skipped because of stale rows. +- Startup repair does not send delivery notifications for historical stale rows. + +## Constraints + +- Do not add a new run status. +- Keep the repair in cron startup, not in every active-run check. + +## Open Questions + +None. diff --git a/docs/issues/cron-agent-jobs-trigger-logging/spec.md b/docs/issues/cron-agent-jobs-trigger-logging/spec.md new file mode 100644 index 000000000..192034016 --- /dev/null +++ b/docs/issues/cron-agent-jobs-trigger-logging/spec.md @@ -0,0 +1,25 @@ +# Cron Agent Jobs Trigger Logging + +## User Need + +When a scheduled cron job does not create a session or deliver a result, developers need logs that show where the trigger chain stopped. + +## Goal + +Add focused main-process logs for due-run receipt, run processing, session creation, and prompt dispatch. + +## Acceptance Criteria + +- Logs show when the scheduler reports a due run. +- Logs show when the cron service starts processing a due run. +- Logs show session creation and task dispatch boundaries. +- Logs do not include task prompt content. + +## Constraints + +- Do not add a new tracing subsystem. +- Do not log scheduler heartbeats. + +## Open Questions + +None. diff --git a/docs/issues/cron-job-run-insert-values/spec.md b/docs/issues/cron-job-run-insert-values/spec.md new file mode 100644 index 000000000..0c4435aa3 --- /dev/null +++ b/docs/issues/cron-job-run-insert-values/spec.md @@ -0,0 +1,21 @@ +# Cron Job Run Insert Values + +## User Need + +Manual cron job runs must queue a run row without SQLite insert errors. + +## Goal + +Fix `cron_job_runs` queued-run insertion so the values list matches the Phase 4 column list. + +## Acceptance Criteria + +- `cronJobs.runNow` can insert a queued run. +- New Phase 4 run columns default to `NULL` on queued rows. +- No compatibility fallback or migration is added before first release. + +## Non-Goals + +- No schema rebuild. +- No legacy data migration. + diff --git a/docs/issues/cron-run-open-session-button-removal/spec.md b/docs/issues/cron-run-open-session-button-removal/spec.md new file mode 100644 index 000000000..74dfae1e8 --- /dev/null +++ b/docs/issues/cron-run-open-session-button-removal/spec.md @@ -0,0 +1,19 @@ +# Cron Run Open Session Button Removal + +## User Need + +The run history open-session button is unreliable and should not be shown. + +## Goal + +Remove the Settings UI button that opens a scheduled run session. + +## Acceptance Criteria + +- Run history still shows the latest run timestamp. +- The open-session icon button is no longer rendered. +- No new replacement action is added. + +## Open Questions + +None. diff --git a/docs/issues/cron-scheduled-runs-not-visible/spec.md b/docs/issues/cron-scheduled-runs-not-visible/spec.md new file mode 100644 index 000000000..31b7f222c --- /dev/null +++ b/docs/issues/cron-scheduled-runs-not-visible/spec.md @@ -0,0 +1,24 @@ +# Scheduled Runs Not Visible + +## User Need + +Scheduled jobs should visibly create a new run when their next run time arrives. + +## Goal + +Keep the settings page aligned with scheduler heartbeats so a scheduled run appears without manual reload, and keep service coverage for due-run execution. + +## Acceptance Criteria + +- When scheduler status advances after a due scan, visible job histories refresh. +- A scheduler due event can start a fresh cron run session through `CronJobsService`. +- No extra controls or fallback migration paths are added. + +## Constraints + +- Keep the existing scheduler process and route contracts. +- Keep history display to the latest timestamp only. + +## Non-Goals + +- Do not add push notifications or a new renderer event bus in this fix. diff --git a/docs/issues/cron-scheduler-heartbeat-status/spec.md b/docs/issues/cron-scheduler-heartbeat-status/spec.md new file mode 100644 index 000000000..b5e6758e6 --- /dev/null +++ b/docs/issues/cron-scheduler-heartbeat-status/spec.md @@ -0,0 +1,16 @@ +# Cron Scheduler Heartbeat Status + +## Problem + +The Scheduled settings page can show the scheduler as running while the heartbeat remains `none`. + +The scheduler utility emits `READY` and periodic `HEARTBEAT` events, and the main process stores +`lastHeartbeatAt`. The settings page only loads scheduler status during list/save/toggle/run/restart +actions, so it can keep displaying the initial pre-heartbeat status snapshot. + +## Requirements + +- Refresh the scheduler status indicator while the Scheduled settings page is open. +- Do not reload or overwrite the editable job list during heartbeat refreshes. +- Stop the refresh timer when the settings page unmounts. + diff --git a/docs/issues/cron-scheduler-stop-exit-error/spec.md b/docs/issues/cron-scheduler-stop-exit-error/spec.md new file mode 100644 index 000000000..ac6fd1ddb --- /dev/null +++ b/docs/issues/cron-scheduler-stop-exit-error/spec.md @@ -0,0 +1,15 @@ +# Cron Scheduler Stop Exit Error + +## Problem + +When the user disables the last scheduled task, the scheduler utility can exit while there are no +enabled jobs left. The process manager currently writes `Cron scheduler utility exited with code 1.` +before it checks that no restart is needed, so the settings page shows an error for a user-initiated +stop path. + +## Acceptance Criteria + +- Disabling the last enabled job must not surface a scheduler exit-code error. +- Explicit stop/restart paths must still clear scheduler errors. +- Unexpected exits while enabled jobs remain must still be marked as errors and scheduled for restart. + diff --git a/docs/issues/cron-scheduler-utility-start-crash/spec.md b/docs/issues/cron-scheduler-utility-start-crash/spec.md new file mode 100644 index 000000000..ff790599d --- /dev/null +++ b/docs/issues/cron-scheduler-utility-start-crash/spec.md @@ -0,0 +1,17 @@ +# Cron Scheduler Utility Start Crash + +## Problem + +The cron scheduler utility process can exit with code 1 immediately on startup. + +The utility host imports `openSQLiteDatabase` from `sqlitePresenter/index.ts`. That index module owns +the full main-process SQLite presenter and pulls Electron main-process dependencies into the +scheduler utility bundle. The utility process only needs a lightweight SQLite connection helper. + +## Acceptance Criteria + +- The scheduler utility host must open the database without importing the SQLite presenter index. +- Existing SQLite presenter callers must keep importing `openSQLiteDatabase` from the index module. +- The scheduler utility build output must not import Electron-only dependencies through the SQLite + presenter path. + diff --git a/docs/issues/mcp-plugin-startup-nonblocking/plan.md b/docs/issues/mcp-plugin-startup-nonblocking/plan.md new file mode 100644 index 000000000..378724203 --- /dev/null +++ b/docs/issues/mcp-plugin-startup-nonblocking/plan.md @@ -0,0 +1,6 @@ +# Plan + +1. Replace awaited automatic startup in MCP initialization with handled background starts. +2. Make plugin auto-start fire-and-forget after plugin enablement. +3. Add tests that initialization and plugin enablement do not wait on a hanging start. +4. Run format, i18n, lint, typecheck, and focused tests. diff --git a/docs/issues/mcp-plugin-startup-nonblocking/spec.md b/docs/issues/mcp-plugin-startup-nonblocking/spec.md new file mode 100644 index 000000000..71b968465 --- /dev/null +++ b/docs/issues/mcp-plugin-startup-nonblocking/spec.md @@ -0,0 +1,24 @@ +# MCP Plugin Startup Nonblocking + +## User Need + +Plugin-owned MCP servers must not block main startup or plugin enablement when a server hangs or times out. + +## Goal + +Run automatic MCP server starts in the background while preserving explicit manual start behavior. + +## Acceptance Criteria + +- MCP presenter initialization completes without waiting for enabled server connection attempts. +- A failing enabled plugin MCP server does not prevent other enabled servers from starting. +- Manual `startServer` calls still reject on startup failure. + +## Constraints + +- Do not add a new startup queue abstraction. +- Keep existing MCP server status and error recording behavior. + +## Open Questions + +None. diff --git a/docs/issues/mcp-plugin-startup-nonblocking/tasks.md b/docs/issues/mcp-plugin-startup-nonblocking/tasks.md new file mode 100644 index 000000000..685ab0490 --- /dev/null +++ b/docs/issues/mcp-plugin-startup-nonblocking/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Make automatic MCP startup nonblocking. +- [x] Keep manual MCP start synchronous. +- [x] Add focused test coverage. +- [x] Run validation commands. diff --git a/docs/issues/remove-legacy-scheduled-tasks/spec.md b/docs/issues/remove-legacy-scheduled-tasks/spec.md new file mode 100644 index 000000000..aad47ba58 --- /dev/null +++ b/docs/issues/remove-legacy-scheduled-tasks/spec.md @@ -0,0 +1,23 @@ +# Remove Legacy Scheduled Tasks + +## Problem + +The old `ScheduledTasksService` overlaps with the Cron Jobs implementation and keeps a second +scheduler path alive through ConfigPresenter routes, lifecycle hooks, and renderer clients. + +## Goal + +Remove legacy scheduled-task compatibility so Cron Jobs is the only scheduler. + +## Acceptance Criteria + +- No main-process `ScheduledTasksService` is constructed or started. +- No `scheduledTasks.*` route contract or dispatcher branch remains. +- No renderer `ScheduledTasksClient` or legacy settings component remains. +- ConfigPresenter no longer reads or writes the `scheduledTasks` config key. +- Settings route names may stay if they already point at the new Scheduled page. + +## Non-Goals + +- No migration from legacy scheduled tasks. +- No compatibility fallback for old config. diff --git a/docs/issues/scheduled-ui-label-layout/spec.md b/docs/issues/scheduled-ui-label-layout/spec.md new file mode 100644 index 000000000..a28f5fb80 --- /dev/null +++ b/docs/issues/scheduled-ui-label-layout/spec.md @@ -0,0 +1,23 @@ +# Scheduled UI Label Layout + +## User Need + +Scheduled task settings should use a user-friendly name and avoid over-wide history rows. + +## Goal + +Rename user-facing Cron Jobs labels to Scheduled, constrain the settings content width, and show only +the latest run timestamp. + +## Acceptance Criteria + +- The settings nav and page title use Scheduled semantics in every locale. +- Chinese labels use `定时任务`. +- The Scheduled settings content has a narrower max width. +- Run history shows one latest timestamp, not status, preview text, or multiple rows. + +## Non-Goals + +- No internal route or domain rename. +- No run detail redesign. + diff --git a/docs/issues/settings-save-clone-errors/plan.md b/docs/issues/settings-save-clone-errors/plan.md new file mode 100644 index 000000000..974ff38d0 --- /dev/null +++ b/docs/issues/settings-save-clone-errors/plan.md @@ -0,0 +1,5 @@ +# Plan + +- Normalize Cron Jobs renderer API upsert payloads before IPC. +- Cover the regression with a reactive runtime object and structured clone check. + diff --git a/docs/issues/settings-save-clone-errors/spec.md b/docs/issues/settings-save-clone-errors/spec.md index 0e3cb459c..de55e3a8a 100644 --- a/docs/issues/settings-save-clone-errors/spec.md +++ b/docs/issues/settings-save-clone-errors/spec.md @@ -11,6 +11,7 @@ Fix renderer IPC clone errors when settings save paths receive reactive objects. - Adding, updating, and replacing custom prompts send structured-cloneable payloads. - Adding, updating, and replacing system prompts send structured-cloneable payloads. - Saving shortcut keys sends a structured-cloneable payload. +- Saving Cron Jobs sends a structured-cloneable payload. - Existing saved values and route contracts remain unchanged. - Tests cover the renderer API client payloads with structured clone validation. diff --git a/docs/issues/settings-save-clone-errors/tasks.md b/docs/issues/settings-save-clone-errors/tasks.md new file mode 100644 index 000000000..d36fc631c --- /dev/null +++ b/docs/issues/settings-save-clone-errors/tasks.md @@ -0,0 +1,5 @@ +# Tasks + +- [x] Convert Cron Jobs upsert input to a plain IPC payload. +- [x] Add renderer client regression coverage. +- [x] Run focused validation. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index e175736bc..dfca39839 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -27,7 +27,8 @@ export default defineConfig({ input: { index: resolve('src/main/index.ts'), backgroundExecUtilityHost: resolve('src/main/backgroundExecUtilityHostEntry.ts'), - fileWatcherUtilityHost: resolve('src/main/fileWatcherUtilityHostEntry.ts') + fileWatcherUtilityHost: resolve('src/main/fileWatcherUtilityHostEntry.ts'), + schedulerUtilityHost: resolve('src/main/schedulerUtilityHostEntry.ts') }, external: ['sharp', '@duckdb/node-api'], output: { diff --git a/package.json b/package.json index b32a85f60..47d72fc78 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "axios": "^1.16.1", "better-sqlite3-multiple-ciphers": "12.9.0", "compare-versions": "^6.1.1", + "cron-parser": "^5.6.1", "cross-spawn": "^7.0.6", "diff": "^8.0.4", "electron-log": "^5.4.4", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aba15eeee..3b8ca3ef8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,18 @@ supportedArchitectures: publicHoistPattern: - '@img/sharp-*' + +allowBuilds: + '@parcel/watcher': false + '@tailwindcss/oxide': true + better-sqlite3-multiple-ciphers: false + classic-level: true + electron: true + electron-winstaller: true + esbuild: false + lzo: true + maplibre-gl: false + node-pty: false + protobufjs: false + sharp: false + vue-demi: false diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index da6860cf7..80529eb23 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -61,7 +61,7 @@ { "id": "auggie", "name": "Auggie CLI", - "version": "0.31.0", + "version": "0.32.0", "description": "Augment Code's powerful software agent, backed by industry-leading context engine", "repository": "https://github.com/augmentcode/auggie", "website": "https://www.augmentcode.com/", @@ -72,7 +72,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/auggie.svg", "distribution": { "npx": { - "package": "@augmentcode/auggie@0.31.0", + "package": "@augmentcode/auggie@0.32.0", "args": [ "--acp" ], @@ -103,7 +103,7 @@ { "id": "claude-acp", "name": "Claude Agent", - "version": "0.54.1", + "version": "0.55.0", "description": "ACP wrapper for Anthropic's Claude", "repository": "https://github.com/agentclientprotocol/claude-agent-acp", "authors": [ @@ -114,7 +114,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@agentclientprotocol/claude-agent-acp@0.54.1" + "package": "@agentclientprotocol/claude-agent-acp@0.55.0" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" @@ -163,7 +163,7 @@ { "id": "codex-acp", "name": "Codex", - "version": "1.0.2", + "version": "1.1.0", "description": "ACP adapter for OpenAI's coding assistant", "repository": "https://github.com/agentclientprotocol/codex-acp", "authors": [ @@ -174,7 +174,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@agentclientprotocol/codex-acp@1.0.2" + "package": "@agentclientprotocol/codex-acp@1.1.0" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codex-acp.svg" @@ -331,7 +331,7 @@ { "id": "cursor", "name": "Cursor", - "version": "2026.06.26", + "version": "2026.07.01", "description": "Cursor's coding agent", "website": "https://cursor.com/docs/cli/acp", "authors": [ @@ -341,42 +341,42 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.06.26-7079533/darwin/arm64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.01-41b2de7/darwin/arm64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.06.26-7079533/darwin/x64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.01-41b2de7/darwin/x64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.06.26-7079533/linux/arm64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.01-41b2de7/linux/arm64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.06.26-7079533/linux/x64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.01-41b2de7/linux/x64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "windows-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.06.26-7079533/windows/arm64/agent-cli-package.zip", + "archive": "https://downloads.cursor.com/lab/2026.07.01-41b2de7/windows/arm64/agent-cli-package.zip", "cmd": "./dist-package\\cursor-agent.cmd", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.06.26-7079533/windows/x64/agent-cli-package.zip", + "archive": "https://downloads.cursor.com/lab/2026.07.01-41b2de7/windows/x64/agent-cli-package.zip", "cmd": "./dist-package\\cursor-agent.cmd", "args": [ "acp" @@ -467,7 +467,7 @@ { "id": "dimcode", "name": "DimCode", - "version": "0.2.15", + "version": "0.2.17", "description": "A coding agent that puts leading models at your command.", "website": "https://dimcode.dev/docs/acp.html", "authors": [ @@ -476,7 +476,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "dimcode@0.2.15", + "package": "dimcode@0.2.17", "args": [ "acp" ] @@ -487,7 +487,7 @@ { "id": "dirac", "name": "Dirac", - "version": "0.4.12", + "version": "0.4.13", "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", @@ -498,7 +498,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dirac.svg", "distribution": { "npx": { - "package": "dirac-cli@0.4.12", + "package": "dirac-cli@0.4.13", "args": [ "--acp" ] @@ -508,7 +508,7 @@ { "id": "factory-droid", "name": "Factory Droid", - "version": "0.161.0", + "version": "0.164.0", "description": "Factory Droid - AI coding agent powered by Factory AI", "website": "https://factory.ai/product/cli", "authors": [ @@ -517,7 +517,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "droid@0.161.0", + "package": "droid@0.164.0", "args": [ "exec", "--output-format", @@ -534,7 +534,7 @@ { "id": "fast-agent", "name": "fast-agent", - "version": "0.8.2", + "version": "0.8.3", "description": "Code and build agents with comprehensive multi-provider support", "repository": "https://github.com/evalstate/fast-agent", "website": "https://fast-agent.ai", @@ -544,7 +544,7 @@ "license": "Apache 2.0", "distribution": { "uvx": { - "package": "fast-agent-acp==0.8.2", + "package": "fast-agent-acp==0.8.3", "args": [ "-x" ] @@ -576,7 +576,7 @@ { "id": "github-copilot-cli", "name": "GitHub Copilot", - "version": "1.0.67", + "version": "1.0.68", "description": "GitHub's AI pair programmer", "repository": "https://github.com/github/copilot-cli", "website": "https://github.com/features/copilot/cli/", @@ -586,7 +586,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@github/copilot@1.0.67", + "package": "@github/copilot@1.0.68", "args": [ "--acp" ] @@ -614,7 +614,7 @@ { "id": "goose", "name": "goose", - "version": "1.39.0", + "version": "1.41.0", "description": "A local, extensible, open source AI agent that automates engineering tasks", "repository": "https://github.com/block/goose", "website": "https://block.github.io/goose/", @@ -625,35 +625,35 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/block/goose/releases/download/v1.39.0/goose-aarch64-apple-darwin.tar.bz2", + "archive": "https://github.com/block/goose/releases/download/v1.41.0/goose-aarch64-apple-darwin.tar.bz2", "cmd": "./goose", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://github.com/block/goose/releases/download/v1.39.0/goose-x86_64-apple-darwin.tar.bz2", + "archive": "https://github.com/block/goose/releases/download/v1.41.0/goose-x86_64-apple-darwin.tar.bz2", "cmd": "./goose", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://github.com/block/goose/releases/download/v1.39.0/goose-aarch64-unknown-linux-gnu.tar.bz2", + "archive": "https://github.com/block/goose/releases/download/v1.41.0/goose-aarch64-unknown-linux-gnu.tar.bz2", "cmd": "./goose", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://github.com/block/goose/releases/download/v1.39.0/goose-x86_64-unknown-linux-gnu.tar.bz2", + "archive": "https://github.com/block/goose/releases/download/v1.41.0/goose-x86_64-unknown-linux-gnu.tar.bz2", "cmd": "./goose", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://github.com/block/goose/releases/download/v1.39.0/goose-x86_64-pc-windows-msvc.zip", + "archive": "https://github.com/block/goose/releases/download/v1.41.0/goose-x86_64-pc-windows-msvc.zip", "cmd": "./goose-package\\goose.exe", "args": [ "acp" @@ -666,7 +666,7 @@ { "id": "grok-build", "name": "Grok Build", - "version": "0.2.79", + "version": "0.2.84", "description": "xAI's coding agent and CLI", "website": "https://x.ai/cli", "authors": [ @@ -675,7 +675,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@xai-official/grok@0.2.79", + "package": "@xai-official/grok@0.2.84", "args": [ "agent", "stdio" @@ -869,7 +869,7 @@ { "id": "mistral-vibe", "name": "Mistral Vibe", - "version": "2.18.3", + "version": "2.18.4", "description": "Mistral's open-source coding assistant", "repository": "https://github.com/mistralai/mistral-vibe", "website": "https://mistral.ai/products/vibe", @@ -881,23 +881,23 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.3/vibe-acp-darwin-aarch64-2.18.3.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-darwin-aarch64-2.18.4.zip", "cmd": "./vibe-acp" }, "darwin-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.3/vibe-acp-darwin-x86_64-2.18.3.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-darwin-x86_64-2.18.4.zip", "cmd": "./vibe-acp" }, "linux-aarch64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.3/vibe-acp-linux-aarch64-2.18.3.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-linux-aarch64-2.18.4.zip", "cmd": "./vibe-acp" }, "linux-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.3/vibe-acp-linux-x86_64-2.18.3.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-linux-x86_64-2.18.4.zip", "cmd": "./vibe-acp" }, "windows-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.3/vibe-acp-windows-x86_64-2.18.3.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-windows-x86_64-2.18.4.zip", "cmd": "./vibe-acp.exe" } } @@ -906,7 +906,7 @@ { "id": "nova", "name": "Nova", - "version": "1.1.23", + "version": "1.1.24", "description": "Nova by Compass AI - a fully-fledged software engineer at your command", "repository": "https://github.com/Compass-Agentic-Platform/nova", "website": "https://www.compassap.ai/portfolio/nova.html", @@ -917,7 +917,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg", "distribution": { "npx": { - "package": "@compass-ai/nova@1.1.23", + "package": "@compass-ai/nova@1.1.24", "args": [ "acp" ] @@ -927,7 +927,7 @@ { "id": "opencode", "name": "OpenCode", - "version": "1.17.12", + "version": "1.17.13", "description": "The open source coding agent", "repository": "https://github.com/anomalyco/opencode", "website": "https://opencode.ai", @@ -939,42 +939,42 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.12/opencode-darwin-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.13/opencode-darwin-arm64.zip", "cmd": "./opencode", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.12/opencode-darwin-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.13/opencode-darwin-x64.zip", "cmd": "./opencode", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.12/opencode-linux-arm64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.13/opencode-linux-arm64.tar.gz", "cmd": "./opencode", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.12/opencode-linux-x64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.13/opencode-linux-x64.tar.gz", "cmd": "./opencode", "args": [ "acp" ] }, "windows-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.12/opencode-windows-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.13/opencode-windows-arm64.zip", "cmd": "./opencode", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.12/opencode-windows-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.13/opencode-windows-x64.zip", "cmd": "./opencode.exe", "args": [ "acp" @@ -1082,7 +1082,7 @@ { "id": "qwen-code", "name": "Qwen Code", - "version": "0.19.3", + "version": "0.19.5", "description": "Alibaba's Qwen coding assistant", "repository": "https://github.com/QwenLM/qwen-code", "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", @@ -1092,7 +1092,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@qwen-code/qwen-code@0.19.3", + "package": "@qwen-code/qwen-code@0.19.5", "args": [ "--acp", "--experimental-skills" @@ -1104,7 +1104,7 @@ { "id": "sigit", "name": "siGit Code", - "version": "1.3.0", + "version": "1.3.1", "description": "Local-first coding agent. Runs entirely on your machine with optional on-device LLM inference via Onde.", "repository": "https://github.com/getsigit/sigit", "website": "https://github.com/getsigit/sigit", @@ -1115,32 +1115,32 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.0/sigit-macos-arm64.tar.gz", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-macos-arm64.tar.gz", "cmd": "./sigit" }, "darwin-x86_64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.0/sigit-macos-amd64.tar.gz", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-macos-amd64.tar.gz", "cmd": "./sigit" }, "linux-aarch64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.0/sigit-linux-arm64", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-linux-arm64", "cmd": "./sigit-linux-arm64" }, "linux-x86_64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.0/sigit-linux-amd64", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-linux-amd64", "cmd": "./sigit-linux-amd64" }, "windows-aarch64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.0/sigit-win-arm64.exe", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-win-arm64.exe", "cmd": "./sigit-win-arm64.exe" }, "windows-x86_64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.0/sigit-win-amd64.exe", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-win-amd64.exe", "cmd": "./sigit-win-amd64.exe" } }, "npx": { - "package": "@smbcloud/sigit@1.3.0" + "package": "@smbcloud/sigit@1.3.1" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/sigit.svg" diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 2342d6a9b..ec8787ccb 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -15702,7 +15702,7 @@ ] }, "limit": { - "context": 1000000, + "context": 1040000, "output": 128000 }, "temperature": true, @@ -15721,9 +15721,9 @@ "release_date": "2026-06-16", "last_updated": "2026-06-13", "cost": { - "input": 1.5, - "output": 4.5, - "cache_read": 0.3 + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 }, "type": "chat" }, @@ -17056,6 +17056,69 @@ }, "type": "chat" }, + { + "id": "anthropic/claude-fable-5", + "name": "Claude Fable 5", + "display_name": "Claude Fable 5", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "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", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "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." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01-31", + "release_date": "2026-07-01", + "last_updated": "2026-06-09", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "type": "chat" + }, { "id": "anthropic/claude-opus-4.5", "name": "Claude Opus 4.5", @@ -23911,8 +23974,8 @@ "release_date": "2026-04-21", "last_updated": "2026-04-21", "cost": { - "input": 0.95, - "output": 4, + "input": 0.8, + "output": 3.4, "cache_read": 0.16 }, "type": "chat" @@ -24735,14 +24798,14 @@ "release_date": "2026-04-22", "last_updated": "2026-05-27", "cost": { - "input": 2, - "output": 6, - "cache_read": 0.4, + "input": 0.522, + "output": 1.044, + "cache_read": 0.0043, "tiers": [ { - "input": 2, - "output": 6, - "cache_read": 0.4, + "input": 0.522, + "output": 1.044, + "cache_read": 0.0043, "tier": { "type": "context", "size": 256000 @@ -24750,9 +24813,9 @@ } ], "context_over_200k": { - "input": 2, - "output": 6, - "cache_read": 0.4 + "input": 0.522, + "output": 1.044, + "cache_read": 0.0043 } }, "type": "chat" @@ -24982,7 +25045,7 @@ "release_date": "2026-03-27", "last_updated": "2026-03-27", "cost": { - "input": 1.4, + "input": 1.38, "output": 4.4, "cache_read": 0.26 }, @@ -25141,7 +25204,8 @@ "last_updated": "2025-10-13", "cost": { "input": 0.13, - "output": 0.85 + "output": 0.85, + "cache_read": 0.025 }, "type": "chat" }, @@ -25590,7 +25654,7 @@ "cost": { "input": 1.25, "output": 3.75, - "cache_read": 0.125, + "cache_read": 0.25, "cache_write": 1.5625 }, "type": "chat" @@ -26096,8 +26160,8 @@ "release_date": "2025-07-23", "last_updated": "2025-07-23", "cost": { - "input": 0.3, - "output": 1.3 + "input": 0.38, + "output": 1.55 }, "type": "chat" }, @@ -26654,9 +26718,9 @@ "release_date": "2026-04-24", "last_updated": "2026-04-24", "cost": { - "input": 1.69, - "output": 3.38, - "cache_read": 0.13 + "input": 1.6, + "output": 3.2, + "cache_read": 0.135 }, "type": "chat" }, @@ -34521,6 +34585,230 @@ } ] }, + "trustedrouter": { + "id": "trustedrouter", + "name": "TrustedRouter", + "display_name": "TrustedRouter", + "api": "https://api.trustedrouter.com/v1", + "doc": "https://trustedrouter.com/docs", + "models": [ + { + "id": "zdr", + "name": "Zero Data Retention", + "display_name": "Zero Data Retention", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-06-01", + "last_updated": "2026-06-27", + "type": "chat" + }, + { + "id": "e2e", + "name": "End-to-End Encrypted", + "display_name": "End-to-End Encrypted", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-06-01", + "last_updated": "2026-06-27", + "type": "chat" + }, + { + "id": "synth-code", + "name": "Synth Code", + "display_name": "Synth Code", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-06-20", + "last_updated": "2026-06-27", + "type": "chat" + }, + { + "id": "fast", + "name": "Fast", + "display_name": "Fast", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-06-01", + "last_updated": "2026-06-27", + "type": "chat" + }, + { + "id": "synth", + "name": "Synth", + "display_name": "Synth", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-06-20", + "last_updated": "2026-06-27", + "type": "chat" + }, + { + "id": "auto", + "name": "Auto", + "display_name": "Auto", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "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-05-01", + "last_updated": "2026-06-27", + "type": "chat" + }, + { + "id": "cheap", + "name": "Cheap", + "display_name": "Cheap", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-05-01", + "last_updated": "2026-06-27", + "type": "chat" + } + ] + }, "zhipuai": { "id": "zhipuai", "name": "Zhipu AI", @@ -34566,9 +34854,9 @@ "release_date": "2026-03-27", "last_updated": "2026-03-27", "cost": { - "input": 6, - "output": 24, - "cache_read": 1.3, + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, "cache_write": 0 }, "type": "chat" @@ -39303,6 +39591,67 @@ "api": "https://api.stepfun.ai/step_plan/v1", "doc": "https://platform.stepfun.ai/docs/en/step-plan/integrations/open-code", "models": [ + { + "id": "step-2-16k", + "name": "Step 2 (16K)", + "display_name": "Step 2 (16K)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 16384, + "output": 8192 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-06", + "release_date": "2025-01-01", + "last_updated": "2026-02-13", + "cost": { + "input": 5.21, + "output": 16.44, + "cache_read": 1.04 + }, + "type": "chat" + }, + { + "id": "step-tts-2", + "name": "Step TTS 2", + "display_name": "Step TTS 2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-03-01", + "last_updated": "2026-07-02", + "type": "chat" + }, { "id": "step-3.5-flash", "name": "Step 3.5 Flash", @@ -39325,6 +39674,17 @@ "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-01", @@ -39337,6 +39697,60 @@ }, "type": "chat" }, + { + "id": "stepaudio-2.5-asr", + "name": "StepAudio 2.5 ASR", + "display_name": "StepAudio 2.5 ASR", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-04-24", + "last_updated": "2026-07-02", + "type": "chat" + }, + { + "id": "stepaudio-2.5-tts", + "name": "StepAudio 2.5 TTS", + "display_name": "StepAudio 2.5 TTS", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-04-16", + "last_updated": "2026-07-02", + "type": "chat" + }, { "id": "step-3.5-flash-2603", "name": "Step 3.5 Flash 2603", @@ -39359,6 +39773,17 @@ "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-01", @@ -39370,6 +39795,86 @@ "cache_read": 0.02 }, "type": "chat" + }, + { + "id": "step-3.7-flash", + "name": "Step 3.7 Flash", + "display_name": "Step 3.7 Flash", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "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": "2026-01-01", + "release_date": "2026-05-29", + "last_updated": "2026-06-29", + "cost": { + "input": 0.2, + "output": 1.15, + "cache_read": 0.04 + }, + "type": "chat" + }, + { + "id": "step-1-32k", + "name": "Step 1 (32K)", + "display_name": "Step 1 (32K)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 32768, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "knowledge": "2024-06", + "release_date": "2025-01-01", + "last_updated": "2026-02-13", + "cost": { + "input": 2.05, + "output": 9.59, + "cache_read": 0.41 + }, + "type": "chat" } ] }, @@ -44359,8 +44864,8 @@ }, { "id": "minimax-m3", - "name": "MiniMax-M3 (3x usage)", - "display_name": "MiniMax-M3 (3x usage)", + "name": "MiniMax-M3", + "display_name": "MiniMax-M3", "modalities": { "input": [ "text", @@ -49146,6 +49651,47 @@ }, "type": "chat" }, + { + "id": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "display_name": "Claude Sonnet 5", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 64000 + }, + "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-29", + "last_updated": "2026-07-01", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "type": "chat" + }, { "id": "openai-gpt-53-codex", "name": "GPT-5.3 Codex", @@ -50597,49 +51143,6 @@ }, "type": "chat" }, - { - "id": "arcee-trinity-large-thinking", - "name": "Trinity Large Thinking", - "display_name": "Trinity Large Thinking", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 256000, - "output": 65536 - }, - "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-02", - "last_updated": "2026-06-11", - "cost": { - "input": 0.3125, - "output": 1.125, - "cache_read": 0.075 - }, - "type": "chat" - }, { "id": "openai-gpt-55", "name": "GPT-5.5", @@ -51269,6 +51772,947 @@ } ] }, + "kenari": { + "id": "kenari", + "name": "Kenari", + "display_name": "Kenari", + "api": "https://kenari.id/v1", + "doc": "https://kenari.id/docs", + "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, + "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", + "cost": { + "input": 0, + "output": 0 + }, + "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 + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "glm-5-1", + "name": "GLM-5.1", + "display_name": "GLM-5.1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "cost": { + "input": 0, + "output": 0 + }, + "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": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-02", + "last_updated": "2026-04-02", + "cost": { + "input": 0, + "output": 0 + }, + "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": 30000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-04-17", + "last_updated": "2026-04-17", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "qwen3-7-plus", + "name": "Qwen3.7 Plus", + "display_name": "Qwen3.7 Plus", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "knowledge": "2025-04", + "release_date": "2026-06-02", + "last_updated": "2026-06-02", + "cost": { + "input": 0, + "output": 0 + }, + "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 + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "cost": { + "input": 0, + "output": 0 + }, + "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, + "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", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "deepseek-v4-flash:free", + "name": "DeepSeek V4 Flash (Free)", + "display_name": "DeepSeek V4 Flash (Free)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "cost": { + "input": 0, + "output": 0 + }, + "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", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "minimax-m3", + "name": "MiniMax-M3", + "display_name": "MiniMax-M3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 512000, + "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", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "display_name": "Claude Opus 4.8", + "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, + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "gpt-5-4-mini", + "name": "GPT-5.4 mini", + "display_name": "GPT-5.4 mini", + "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", + "cost": { + "input": 0, + "output": 0 + }, + "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": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "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", + "cost": { + "input": 0, + "output": 0 + }, + "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": 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-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "cost": { + "input": 0, + "output": 0 + }, + "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 + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "cost": { + "input": 0, + "output": 0 + }, + "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, + "default": true + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-04-16", + "last_updated": "2026-04-16", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "display_name": "Claude Sonnet 4.6", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "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." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-17", + "last_updated": "2026-03-13", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "gpt-oss-20b", + "name": "GPT OSS 20B", + "display_name": "GPT OSS 20B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "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", + "cost": { + "input": 0, + "output": 0 + }, + "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": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2024-12", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "deepseek-v4-pro:free", + "name": "DeepSeek V4 Pro (Free)", + "display_name": "DeepSeek V4 Pro (Free)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 384000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-05", + "release_date": "2026-04-24", + "last_updated": "2026-04-24", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "gpt-image-2", + "name": "GPT-Image-2", + "display_name": "GPT-Image-2", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "image" + ] + }, + "limit": { + "context": 272000, + "output": 16384 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-04-21", + "last_updated": "2026-04-21", + "cost": { + "input": 0, + "output": 0 + }, + "type": "imageGeneration" + } + ] + }, "openai": { "id": "openai", "name": "OpenAI", @@ -54628,6 +56072,49 @@ } ] }, + "tencent-token-plan": { + "id": "tencent-token-plan", + "name": "Tencent Token Plan", + "display_name": "Tencent Token Plan", + "api": "https://api.lkeap.cloud.tencent.com/plan/v3", + "doc": "https://cloud.tencent.com/document/product/1823/130060", + "models": [ + { + "id": "hy3", + "name": "Hy3", + "display_name": "Hy3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-07-06", + "last_updated": "2026-07-06", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + } + ] + }, "github-models": { "id": "github-models", "name": "GitHub Models", @@ -80342,10 +81829,10 @@ "release_date": "2026-06-30", "last_updated": "2026-06-30", "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5 }, "type": "chat" }, @@ -81872,6 +83359,69 @@ }, "type": "chat" }, + { + "id": "claude-fable-5", + "name": "Claude Fable 5", + "display_name": "Claude Fable 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, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "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." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "type": "chat" + }, { "id": "sonar", "name": "Sonar", @@ -96760,6 +98310,39 @@ "display_name": "Cerebras", "doc": "https://inference-docs.cerebras.ai/models/overview", "models": [ + { + "id": "gemma-4-31b", + "name": "Gemma 4 31B IT", + "display_name": "Gemma 4 31B IT", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 40960 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-02", + "last_updated": "2026-07-01", + "cost": { + "input": 0.99, + "output": 1.49 + }, + "type": "chat" + }, { "id": "gpt-oss-120b", "name": "GPT OSS 120B", @@ -109541,6 +111124,17 @@ "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, "attachment": true, "open_weights": true, "knowledge": "2026-01-01", @@ -109575,6 +111169,17 @@ "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-01", @@ -109587,6 +111192,60 @@ }, "type": "chat" }, + { + "id": "stepaudio-2.5-tts", + "name": "StepAudio 2.5 TTS", + "display_name": "StepAudio 2.5 TTS", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-04-16", + "last_updated": "2026-07-02", + "type": "chat" + }, + { + "id": "stepaudio-2.5-asr", + "name": "StepAudio 2.5 ASR", + "display_name": "StepAudio 2.5 ASR", + "modalities": { + "input": [ + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-04-24", + "last_updated": "2026-07-02", + "type": "chat" + }, { "id": "step-3.5-flash", "name": "Step 3.5 Flash", @@ -109609,6 +111268,17 @@ "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-01", @@ -109621,6 +111291,33 @@ }, "type": "chat" }, + { + "id": "step-tts-2", + "name": "Step TTS 2", + "display_name": "Step TTS 2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-03-01", + "last_updated": "2026-07-02", + "type": "chat" + }, { "id": "step-2-16k", "name": "Step 2 (16K)", @@ -120503,6 +122200,63 @@ }, "type": "chat" }, + { + "id": "openai/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": 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-12-01", + "release_date": "2026-04-23", + "last_updated": "2026-04-23", + "cost": { + "input": 5, + "output": 30, + "cache_read": 0.5 + }, + "type": "chat" + }, { "id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7", @@ -120563,6 +122317,67 @@ }, "type": "chat" }, + { + "id": "anthropic/claude-opus-4.8", + "name": "Claude Opus 4.8", + "display_name": "Claude Opus 4.8", + "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, + "release_date": "2026-05-28", + "last_updated": "2026-05-28", + "cost": { + "input": 5, + "output": 25, + "cache_read": 0.5 + }, + "type": "chat" + }, { "id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", @@ -122677,6 +124492,40 @@ "api": "https://tokenhub.tencentmaas.com/v1", "doc": "https://cloud.tencent.com/document/product/1823/130050", "models": [ + { + "id": "hy3", + "name": "Hy3", + "display_name": "Hy3", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-07-06", + "last_updated": "2026-07-06", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "hy3-preview", "name": "Hy3 preview", @@ -123558,6 +125407,43 @@ }, "type": "chat" }, + { + "id": "duo-chat-fable-5", + "name": "Agentic Chat (Claude Fable 5)", + "display_name": "Agentic Chat (Claude Fable 5)", + "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": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "duo-chat-gpt-5-5", "name": "Agentic Chat (GPT-5.5)", @@ -123805,78 +125691,9 @@ "type": "chat" }, { - "id": "duo-chat-gpt-5-4-mini", - "name": "Agentic Chat (GPT-5.4 Mini)", - "display_name": "Agentic Chat (GPT-5.4 Mini)", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 400000, - "output": 128000 - }, - "temperature": false, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": true, - "open_weights": false, - "knowledge": "2025-08-31", - "release_date": "2026-03-17", - "last_updated": "2026-03-17", - "cost": { - "input": 0, - "output": 0 - }, - "type": "chat" - }, - { - "id": "duo-chat-gpt-5-3-codex", - "name": "Agentic Chat (GPT-5.3 Codex)", - "display_name": "Agentic Chat (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 - }, - "attachment": true, - "open_weights": false, - "knowledge": "2025-08-31", - "release_date": "2026-02-05", - "last_updated": "2026-02-05", - "cost": { - "input": 0, - "output": 0 - }, - "type": "chat" - }, - { - "id": "duo-chat-haiku-4-5", - "name": "Agentic Chat (Claude Haiku 4.5)", - "display_name": "Agentic Chat (Claude Haiku 4.5)", + "id": "duo-chat-sonnet-5", + "name": "Agentic Chat (Claude Sonnet 5)", + "display_name": "Agentic Chat (Claude Sonnet 5)", "modalities": { "input": [ "text", @@ -123888,45 +125705,9 @@ ] }, "limit": { - "context": 200000, + "context": 1000000, "output": 64000 }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": true, - "open_weights": false, - "knowledge": "2025-02-28", - "release_date": "2026-01-08", - "last_updated": "2026-01-08", - "cost": { - "input": 0, - "output": 0, - "cache_read": 0, - "cache_write": 0 - }, - "type": "chat" - }, - { - "id": "duo-chat-gpt-5-2", - "name": "Agentic Chat (GPT-5.2)", - "display_name": "Agentic Chat (GPT-5.2)", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 400000, - "output": 128000 - }, "temperature": false, "tool_call": true, "reasoning": { @@ -123935,44 +125716,9 @@ }, "attachment": true, "open_weights": false, - "knowledge": "2025-08-31", - "release_date": "2026-01-23", - "last_updated": "2026-01-23", - "cost": { - "input": 0, - "output": 0 - }, - "type": "chat" - }, - { - "id": "duo-chat-sonnet-4-5", - "name": "Agentic Chat (Claude Sonnet 4.5)", - "display_name": "Agentic Chat (Claude Sonnet 4.5)", - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 200000, - "output": 64000 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "attachment": true, - "open_weights": false, - "knowledge": "2025-07-31", - "release_date": "2026-01-08", - "last_updated": "2026-01-08", + "knowledge": "2026-01-31", + "release_date": "2026-06-30", + "last_updated": "2026-06-30", "cost": { "input": 0, "output": 0, @@ -123982,9 +125728,186 @@ "type": "chat" }, { - "id": "duo-chat-gpt-5-1", - "name": "Agentic Chat (GPT-5.1)", - "display_name": "Agentic Chat (GPT-5.1)", + "id": "duo-chat-gpt-5-4-mini", + "name": "Agentic Chat (GPT-5.4 Mini)", + "display_name": "Agentic Chat (GPT-5.4 Mini)", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-17", + "last_updated": "2026-03-17", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "duo-chat-gpt-5-3-codex", + "name": "Agentic Chat (GPT-5.3 Codex)", + "display_name": "Agentic Chat (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 + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-02-05", + "last_updated": "2026-02-05", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "duo-chat-haiku-4-5", + "name": "Agentic Chat (Claude Haiku 4.5)", + "display_name": "Agentic Chat (Claude Haiku 4.5)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-02-28", + "release_date": "2026-01-08", + "last_updated": "2026-01-08", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, + { + "id": "duo-chat-gpt-5-2", + "name": "Agentic Chat (GPT-5.2)", + "display_name": "Agentic Chat (GPT-5.2)", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-01-23", + "last_updated": "2026-01-23", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, + { + "id": "duo-chat-sonnet-4-5", + "name": "Agentic Chat (Claude Sonnet 4.5)", + "display_name": "Agentic Chat (Claude Sonnet 4.5)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": false, + "knowledge": "2025-07-31", + "release_date": "2026-01-08", + "last_updated": "2026-01-08", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, + { + "id": "duo-chat-gpt-5-1", + "name": "Agentic Chat (GPT-5.1)", + "display_name": "Agentic Chat (GPT-5.1)", "modalities": { "input": [ "text", @@ -138385,7 +140308,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -139527,6 +141451,69 @@ }, "type": "chat" }, + { + "id": "anthropic.claude-fable-5", + "name": "Claude Fable 5", + "display_name": "Claude Fable 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, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "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." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01-31", + "release_date": "2026-06-09", + "last_updated": "2026-06-09", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "type": "chat" + }, { "id": "writer.palmyra-x4-v1:0", "name": "Palmyra X4", @@ -140508,7 +142495,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -142399,7 +144387,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "pdf" ], "output": [ "text" @@ -142850,10 +144839,10 @@ "open_weights": true, "knowledge": "2023-12", "release_date": "2024-12-06", - "last_updated": "2024-12-06", + "last_updated": "2026-07-02", "cost": { - "input": 0.88, - "output": 0.88 + "input": 1.04, + "output": 1.04 }, "type": "chat" }, @@ -143075,7 +145064,7 @@ "attachment": false, "open_weights": false, "release_date": "2026-05-21", - "last_updated": "2026-06-15", + "last_updated": "2026-07-02", "cost": { "input": 1.25, "output": 3.75 @@ -143630,7 +145619,7 @@ "open_weights": true, "knowledge": "2025-11", "release_date": "2026-04-07", - "last_updated": "2026-04-07", + "last_updated": "2026-07-02", "cost": { "input": 1.4, "output": 4.4 @@ -186416,6 +188405,88 @@ }, "type": "chat" }, + { + "id": "kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 32000 + }, + "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", + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.19 + }, + "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", + "cost": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "type": "chat" + }, { "id": "gpt-5.4-nano", "name": "GPT-5.4 nano", @@ -186536,6 +188607,40 @@ }, "type": "chat" }, + { + "id": "mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "display_name": "MAI-Code-1-Flash", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "knowledge": "2025-12", + "release_date": "2026-06-02", + "last_updated": "2026-06-08", + "cost": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "type": "chat" + }, { "id": "gpt-5.2", "name": "GPT-5.2", @@ -188183,9 +190288,9 @@ "doc": "https://synthetic.new/pricing", "models": [ { - "id": "hf:moonshotai/Kimi-K2.6", - "name": "Kimi K2.6", - "display_name": "Kimi K2.6", + "id": "hf:moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", "modalities": { "input": [ "text", @@ -188199,7 +190304,7 @@ "context": 262144, "output": 65536 }, - "temperature": true, + "temperature": false, "tool_call": true, "reasoning": { "supported": true, @@ -188219,8 +190324,8 @@ "attachment": true, "open_weights": true, "knowledge": "2025-01", - "release_date": "2026-04-21", - "last_updated": "2026-04-21", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", "cost": { "input": 0.95, "output": 4, @@ -188273,51 +190378,6 @@ }, "type": "chat" }, - { - "id": "hf:zai-org/GLM-4.7", - "name": "GLM-4.7", - "display_name": "GLM-4.7", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 202752, - "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": false, - "open_weights": true, - "knowledge": "2025-04", - "release_date": "2025-12-22", - "last_updated": "2025-12-22", - "cost": { - "input": 0.45, - "output": 2.19, - "cache_read": 0.45 - }, - "type": "chat" - }, { "id": "hf:zai-org/GLM-5.2", "name": "GLM-5.2", @@ -188362,50 +190422,6 @@ }, "type": "chat" }, - { - "id": "hf:zai-org/GLM-5.1", - "name": "GLM-5.1", - "display_name": "GLM-5.1", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 196608, - "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": false, - "open_weights": true, - "release_date": "2026-04-07", - "last_updated": "2026-04-07", - "cost": { - "input": 1, - "output": 3, - "cache_read": 1 - }, - "type": "chat" - }, { "id": "hf:MiniMaxAI/MiniMax-M3", "name": "MiniMax-M3", @@ -188534,51 +190550,6 @@ }, "type": "chat" }, - { - "id": "hf:Qwen/Qwen3.5-397B-A17B", - "name": "Qwen3.5 397B-A17B", - "display_name": "Qwen3.5 397B-A17B", - "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-15", - "last_updated": "2026-02-15", - "cost": { - "input": 0.6, - "output": 3.6, - "cache_read": 0.6 - }, - "type": "chat" - }, { "id": "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", "name": "Nemotron 3 Super 120B A12B", @@ -206656,7 +208627,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206681,7 +208652,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206706,7 +208677,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206730,7 +208701,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206754,7 +208725,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206778,7 +208749,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206803,7 +208774,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206828,7 +208799,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "embedding" }, { @@ -206853,7 +208824,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "embedding" }, { @@ -206879,7 +208850,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206905,7 +208876,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206931,7 +208902,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206957,7 +208928,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -206983,7 +208954,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207009,7 +208980,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207035,7 +209006,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207061,7 +209032,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207087,7 +209058,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207113,7 +209084,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207139,7 +209110,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207165,7 +209136,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207191,7 +209162,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207217,7 +209188,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207241,7 +209212,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207267,7 +209238,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207293,7 +209264,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207319,7 +209290,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207343,7 +209314,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" }, { @@ -207364,7 +209335,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z" + "last_updated": "2026-07-03T04:02:17Z" }, { "id": "doubao-seedance-1-0-pro-fast-251015", @@ -207384,7 +209355,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z" + "last_updated": "2026-07-03T04:02:17Z" }, { "id": "doubao-seedance-1-5-pro-251215", @@ -207405,7 +209376,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z" + "last_updated": "2026-07-03T04:02:17Z" }, { "id": "doubao-seedance-2-0-260128", @@ -207427,7 +209398,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z" + "last_updated": "2026-07-03T04:02:17Z" }, { "id": "doubao-seedance-2-0-fast-260128", @@ -207449,7 +209420,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z" + "last_updated": "2026-07-03T04:02:17Z" }, { "id": "doubao-seedance-2-0-mini-260615", @@ -207471,7 +209442,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z" + "last_updated": "2026-07-03T04:02:17Z" }, { "id": "doubao-seedream-4-0-250828", @@ -207487,7 +209458,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "imageGeneration" }, { @@ -207504,7 +209475,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "imageGeneration" }, { @@ -207525,7 +209496,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", "type": "imageGeneration" }, { @@ -207550,7 +209521,32 @@ "default": true }, "attachment": false, - "last_updated": "2026-06-27T07:10:57Z", + "last_updated": "2026-07-03T04:02:17Z", + "type": "chat" + }, + { + "id": "glm-5-2-260617", + "name": "glm-5-2-260617", + "display_name": "glm-5-2-260617", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1024000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "last_updated": "2026-07-03T04:02:17Z", "type": "chat" } ] @@ -211186,9 +213182,9 @@ "type": "chat" }, { - "id": "claude-opus-4-8", - "name": "claude-opus-4-8", - "display_name": "claude-opus-4-8", + "id": "claude-fable-5", + "name": "claude-fable-5", + "display_name": "claude-fable-5", "modalities": { "input": [ "text", @@ -211196,18 +213192,18 @@ ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 1000000, + "output": 1000000 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": false, + "default_enabled": true, "mode": "effort", "effort": "high", "effort_options": [ @@ -211224,55 +213220,107 @@ "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." + "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." ] } }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5 + "input": 11, + "output": 55, + "cache_read": 1.1 }, "type": "chat" }, { - "id": "doubao-seed-2-1-pro", - "name": "doubao-seed-2-1-pro", - "display_name": "doubao-seed-2-1-pro", + "id": "claude-opus-4-8", + "name": "claude-opus-4-8", + "display_name": "claude-opus-4-8", "modalities": { "input": [ "text", - "image", - "video" + "image" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 200000, + "output": 200000 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { - "supported": true + "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." + ] } }, "cost": { - "input": 0.9295, - "output": 4.6475, - "cache_read": 0.1859 + "input": 5, + "output": 25, + "cache_read": 0.5 }, "type": "chat" }, { - "id": "doubao-seed-2-1-turbo", - "name": "doubao-seed-2-1-turbo", - "display_name": "doubao-seed-2-1-turbo", + "id": "doubao-seed-2-1-pro", + "name": "doubao-seed-2-1-pro", + "display_name": "doubao-seed-2-1-pro", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.9295, + "output": 4.6475, + "cache_read": 0.1859 + }, + "type": "chat" + }, + { + "id": "doubao-seed-2-1-turbo", + "name": "doubao-seed-2-1-turbo", + "display_name": "doubao-seed-2-1-turbo", "modalities": { "input": [ "text", @@ -211379,6 +213427,56 @@ }, "type": "chat" }, + { + "id": "mai-image-2.5", + "name": "mai-image-2.5", + "display_name": "mai-image-2.5", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 5, + "output": 5, + "cache_read": 5 + }, + "type": "imageGeneration" + }, + { + "id": "mai-image-2.5-flash", + "name": "mai-image-2.5-flash", + "display_name": "mai-image-2.5-flash", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 1.75, + "output": 1.75, + "cache_read": 1.75 + }, + "type": "imageGeneration" + }, { "id": "happyhorse-1.1-i2v", "name": "happyhorse-1.1-i2v", @@ -218450,12 +220548,11 @@ "type": "chat" }, { - "id": "wan2.6-i2v", - "name": "wan2.6-i2v", - "display_name": "wan2.6-i2v", + "id": "wan2.6-t2v", + "name": "wan2.6-t2v", + "display_name": "wan2.6-t2v", "modalities": { "input": [ - "image", "text" ] }, @@ -218474,11 +220571,12 @@ "type": "chat" }, { - "id": "wan2.6-t2v", - "name": "wan2.6-t2v", - "display_name": "wan2.6-t2v", + "id": "wan2.6-i2v", + "name": "wan2.6-i2v", + "display_name": "wan2.6-i2v", "modalities": { "input": [ + "image", "text" ] }, @@ -218497,13 +220595,12 @@ "type": "chat" }, { - "id": "wan2.2-i2v-plus", - "name": "wan2.2-i2v-plus", - "display_name": "wan2.2-i2v-plus", + "id": "wan2.5-t2v-preview", + "name": "wan2.5-t2v-preview", + "display_name": "wan2.5-t2v-preview", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { @@ -218521,9 +220618,9 @@ "type": "chat" }, { - "id": "wan2.5-i2v-preview", - "name": "wan2.5-i2v-preview", - "display_name": "wan2.5-i2v-preview", + "id": "wan2.2-i2v-plus", + "name": "wan2.2-i2v-plus", + "display_name": "wan2.2-i2v-plus", "modalities": { "input": [ "text", @@ -218545,12 +220642,13 @@ "type": "chat" }, { - "id": "wan2.5-t2v-preview", - "name": "wan2.5-t2v-preview", - "display_name": "wan2.5-t2v-preview", + "id": "wan2.5-i2v-preview", + "name": "wan2.5-i2v-preview", + "display_name": "wan2.5-i2v-preview", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { @@ -226112,6 +228210,42 @@ }, "type": "imageGeneration" }, + { + "id": "jina-reader", + "name": "jina-reader", + "display_name": "jina-reader", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.05, + "output": 0.05 + }, + "type": "chat" + }, + { + "id": "jina-search", + "name": "jina-search", + "display_name": "jina-search", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.05, + "output": 0.05 + }, + "type": "chat" + }, { "id": "llama3.1-8b", "name": "llama3.1-8b", @@ -230675,113 +232809,9 @@ "type": "chat" }, { - "id": "text-davinci-003", - "name": "text-davinci-003", - "display_name": "text-davinci-003", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 20, - "output": 20 - }, - "type": "chat" - }, - { - "id": "text-davinci-edit-001", - "name": "text-davinci-edit-001", - "display_name": "text-davinci-edit-001", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 20, - "output": 20 - }, - "type": "chat" - }, - { - "id": "text-embedding-3-large", - "name": "text-embedding-3-large", - "display_name": "text-embedding-3-large", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.13, - "output": 0.13 - }, - "type": "embedding" - }, - { - "id": "text-embedding-3-small", - "name": "text-embedding-3-small", - "display_name": "text-embedding-3-small", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.02, - "output": 0.02 - }, - "type": "embedding" - }, - { - "id": "tts-1-hd-1106", - "name": "tts-1-hd-1106", - "display_name": "tts-1-hd-1106", - "modalities": { - "input": [ - "audio" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 30, - "output": 30 - } - }, - { - "id": "tts-1-hd", - "name": "tts-1-hd", - "display_name": "tts-1-hd", + "id": "tts-1", + "name": "tts-1", + "display_name": "tts-1", "modalities": { "input": [ "audio" @@ -230796,8 +232826,8 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 + "input": 15, + "output": 15 } }, { @@ -230822,6 +232852,100 @@ "output": 15 } }, + { + "id": "text-search-ada-doc-001", + "name": "text-search-ada-doc-001", + "display_name": "text-search-ada-doc-001", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 20, + "output": 20 + }, + "type": "chat" + }, + { + "id": "text-moderation-stable", + "name": "text-moderation-stable", + "display_name": "text-moderation-stable", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2 + }, + "type": "chat" + }, + { + "id": "text-moderation-latest", + "name": "text-moderation-latest", + "display_name": "text-moderation-latest", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2 + }, + "type": "chat" + }, + { + "id": "text-moderation-007", + "name": "text-moderation-007", + "display_name": "text-moderation-007", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2 + }, + "type": "chat" + }, + { + "id": "tts-1-hd-1106", + "name": "tts-1-hd-1106", + "display_name": "tts-1-hd-1106", + "modalities": { + "input": [ + "audio" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 30, + "output": 30 + } + }, { "id": "whisper-1", "name": "whisper-1", @@ -230892,12 +233016,12 @@ "type": "chat" }, { - "id": "tts-1", - "name": "tts-1", - "display_name": "tts-1", + "id": "text-embedding-v1", + "name": "text-embedding-v1", + "display_name": "text-embedding-v1", "modalities": { "input": [ - "audio" + "text" ] }, "limit": { @@ -230909,14 +233033,20 @@ "supported": false }, "cost": { - "input": 15, - "output": 15 - } + "input": 0.1, + "output": 0.1 + }, + "type": "embedding" }, { - "id": "text-search-ada-doc-001", - "name": "text-search-ada-doc-001", - "display_name": "text-search-ada-doc-001", + "id": "text-embedding-ada-002", + "name": "text-embedding-ada-002", + "display_name": "text-embedding-ada-002", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -230926,15 +233056,20 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.1, + "output": 0.1 }, - "type": "chat" + "type": "embedding" }, { - "id": "text-moderation-stable", - "name": "text-moderation-stable", - "display_name": "text-moderation-stable", + "id": "text-embedding-3-small", + "name": "text-embedding-3-small", + "display_name": "text-embedding-3-small", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -230944,15 +233079,20 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.02, + "output": 0.02 }, - "type": "chat" + "type": "embedding" }, { - "id": "text-moderation-latest", - "name": "text-moderation-latest", - "display_name": "text-moderation-latest", + "id": "text-embedding-3-large", + "name": "text-embedding-3-large", + "display_name": "text-embedding-3-large", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -230962,15 +233102,20 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.13, + "output": 0.13 }, - "type": "chat" + "type": "embedding" }, { - "id": "text-moderation-007", - "name": "text-moderation-007", - "display_name": "text-moderation-007", + "id": "tts-1-hd", + "name": "tts-1-hd", + "display_name": "tts-1-hd", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -230980,20 +233125,14 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 - }, - "type": "chat" + "input": 30, + "output": 30 + } }, { - "id": "text-embedding-v1", - "name": "text-embedding-v1", - "display_name": "text-embedding-v1", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-davinci-edit-001", + "name": "text-davinci-edit-001", + "display_name": "text-davinci-edit-001", "limit": { "context": 8192, "output": 8192 @@ -231003,10 +233142,10 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 20, + "output": 20 }, - "type": "embedding" + "type": "chat" }, { "id": "yi-large", @@ -231117,14 +233256,9 @@ "type": "chat" }, { - "id": "text-embedding-ada-002", - "name": "text-embedding-ada-002", - "display_name": "text-embedding-ada-002", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-davinci-003", + "name": "text-davinci-003", + "display_name": "text-davinci-003", "limit": { "context": 8192, "output": 8192 @@ -231134,10 +233268,10 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 20, + "output": 20 }, - "type": "embedding" + "type": "chat" }, { "id": "meta-llama-3-70b", @@ -233031,8 +235165,8 @@ ] }, "limit": { - "context": 1048575, - "output": 1048575 + "context": 1048576, + "output": 16384 }, "tool_call": true, "reasoning": { @@ -234754,8 +236888,8 @@ ] }, "limit": { - "context": 196608, - "output": 196608 + "context": 204800, + "output": 131072 }, "temperature": true, "tool_call": true, @@ -234806,8 +236940,8 @@ ] }, "limit": { - "context": 196608, - "output": 196608 + "context": 204800, + "output": 131072 }, "temperature": true, "tool_call": true, @@ -235433,7 +237567,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 100352 }, "tool_call": true, "reasoning": { @@ -238399,6 +240533,54 @@ }, "type": "chat" }, + { + "id": "poolside/laguna-xs-2.1", + "name": "Poolside: Laguna XS 2.1", + "display_name": "Poolside: Laguna XS 2.1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, + { + "id": "poolside/laguna-xs-2.1:free", + "name": "Poolside: Laguna XS 2.1 (free)", + "display_name": "Poolside: Laguna XS 2.1 (free)", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, { "id": "poolside/laguna-xs.2", "name": "Poolside: Laguna XS.2", @@ -238818,8 +241000,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 81920, + "output": 32768 }, "tool_call": true, "reasoning": { @@ -238886,7 +241068,7 @@ ] }, "limit": { - "context": 40960, + "context": 131072, "output": 8192 }, "temperature": true, @@ -240829,8 +243011,8 @@ ] }, "limit": { - "context": 65536, - "output": 65536 + "context": 200000, + "output": 128000 }, "temperature": true, "tool_call": true, @@ -240908,9 +243090,9 @@ "display_name": "Jiekou", "models": [ { - "id": "claude-haiku-4-5-20251001", - "name": "claude-haiku-4-5-20251001", - "display_name": "claude-haiku-4-5-20251001", + "id": "claude-fable-5", + "name": "claude-fable-5", + "display_name": "claude-fable-5", "modalities": { "input": [ "text", @@ -240921,86 +243103,46 @@ ] }, "limit": { - "context": 20000, - "output": 20000 + "context": 1000000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": false, - "mode": "budget", - "budget": { - "min": 1024, - "unit": "tokens" - }, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" ], - "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." - ] - } - }, - "type": "chat" - }, - { - "id": "claude-haiku-4-5-20251001-cc", - "name": "claude-haiku-4-5-20251001-cc", - "display_name": "claude-haiku-4-5-20251001-cc", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 200000, - "output": 64000 - }, - "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", + "visibility": "omitted", "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." + "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." ] } }, "type": "chat" }, { - "id": "claude-haiku-4-5-20251001-dd", - "name": "claude-haiku-4-5-20251001-dd", - "display_name": "claude-haiku-4-5-20251001-dd", + "id": "claude-opus-4-7", + "name": "claude-opus-4-7", + "display_name": "claude-opus-4-7", "modalities": { "input": [ "text", @@ -241011,8 +243153,8 @@ ] }, "limit": { - "context": 200000, - "output": 64000 + "context": 1000000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -241023,29 +243165,34 @@ "reasoning": { "supported": true, "default_enabled": false, - "mode": "budget", - "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": [ - "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.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." ] } }, "type": "chat" }, { - "id": "claude-haiku-4-5-20251001-r", - "name": "claude-haiku-4-5-20251001-r", - "display_name": "claude-haiku-4-5-20251001-r", + "id": "claude-sonnet-5", + "name": "claude-sonnet-5", + "display_name": "claude-sonnet-5", "modalities": { "input": [ "text", @@ -241056,147 +243203,88 @@ ] }, "limit": { - "context": 200000, - "output": 64000 + "context": 1000000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "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." - ] + "supported": true } }, "type": "chat" }, { - "id": "claude-opus-4-5-20251101", - "name": "claude-opus-4-5-20251101", - "display_name": "claude-opus-4-5-20251101", + "id": "zai-org/glm-5.2", + "name": "GLM 5.2", + "display_name": "GLM 5.2", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "limit": { - "context": 200000, - "output": 65536 + "context": 1048576, + "output": 163840 }, "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" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "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." - ] + "supported": true } }, "type": "chat" }, { - "id": "claude-opus-4-5-20251101-dd", - "name": "claude-opus-4-5-20251101-dd", - "display_name": "claude-opus-4-5-20251101-dd", + "id": "moonshotai/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 200000, - "output": 65536 + "context": 262144, + "output": 262144 }, "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" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "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." - ] + "supported": true } }, "type": "chat" }, { - "id": "claude-opus-4-6", - "name": "claude-opus-4-6", - "display_name": "claude-opus-4-6", + "id": "minimax/minimax-m3", + "name": "MiniMax M3", + "display_name": "MiniMax M3", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" @@ -241204,47 +243292,24 @@ }, "limit": { "context": 1000000, - "output": 128000 + "output": 131072 }, "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 } }, "type": "chat" }, { - "id": "claude-opus-4-6-cc", - "name": "claude-opus-4-6-cc", - "display_name": "claude-opus-4-6-cc", + "id": "claude-opus-4-8", + "name": "claude-opus-4-8", + "display_name": "claude-opus-4-8", "modalities": { "input": [ "text", @@ -241267,36 +243332,34 @@ "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." ] } }, "type": "chat" }, { - "id": "claude-opus-4-6-dd", - "name": "claude-opus-4-6-dd", - "display_name": "claude-opus-4-6-dd", + "id": "gpt-5.5", + "name": "gpt-5.5", + "display_name": "gpt-5.5", "modalities": { "input": [ "text", @@ -241307,48 +243370,41 @@ ] }, "limit": { - "context": 1000000, + "context": 1050000, "output": 128000 }, "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", + "default_enabled": true, + "mode": "effort", + "effort": "medium", "effort_options": [ "low", "medium", "high", - "max" + "xhigh" ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" ], - "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." - ] + "visibility": "hidden" } }, "type": "chat" }, { - "id": "claude-opus-4-6-r", - "name": "claude-opus-4-6-r", - "display_name": "claude-opus-4-6-r", + "id": "gpt-4.1-nano", + "name": "gpt-4.1-nano", + "display_name": "gpt-4.1-nano", "modalities": { "input": [ "text", @@ -241359,98 +243415,107 @@ ] }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1047576, + "output": 32768 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "gemini-3.5-flash", + "name": "gemini-3.5-flash", + "display_name": "gemini-3.5-flash", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 65536 }, "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": [ + "default_enabled": true, + "mode": "level", + "level": "high", + "level_options": [ + "minimal", "low", "medium", - "high", - "max" + "high" ], - "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." + "thought_signatures" ] } }, "type": "chat" }, { - "id": "claude-opus-4-7", - "name": "claude-opus-4-7", - "display_name": "claude-opus-4-7", + "id": "gemini-3.1-pro-preview", + "name": "gemini-3.1-pro-preview", + "display_name": "gemini-3.1-pro-preview", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 65536 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": false, - "mode": "effort", - "effort": "high", - "effort_options": [ + "default_enabled": true, + "mode": "level", + "level": "high", + "level_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." + "thought_signatures" ] } }, "type": "chat" }, { - "id": "claude-opus-4-7-cc", - "name": "claude-opus-4-7-cc", - "display_name": "claude-opus-4-7-cc", + "id": "grok-4-fast-reasoning", + "name": "grok-4-fast-reasoning", + "display_name": "grok-4-fast-reasoning", "modalities": { "input": [ "text", @@ -241461,146 +243526,327 @@ ] }, "limit": { - "context": 1000000, - "output": 128000 + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, + { + "id": "deepseek/deepseek-v4-flash", + "name": "Deepseek V4 Flash", + "display_name": "Deepseek V4 Flash", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 393216 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "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", + "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." ] } }, "type": "chat" }, { - "id": "claude-opus-4-7-r", - "name": "claude-opus-4-7-r", - "display_name": "claude-opus-4-7-r", + "id": "deepseek/deepseek-v4-pro", + "name": "Deepseek V4 Pro", + "display_name": "Deepseek V4 Pro", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 128000 + "context": 1048576, + "output": 393216 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "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", + "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." ] } }, "type": "chat" }, { - "id": "claude-opus-4-8", - "name": "claude-opus-4-8", - "display_name": "claude-opus-4-8", + "id": "minimax/minimax-m2.7", + "name": "MiniMax M2.7", + "display_name": "MiniMax M2.7", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 131100 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "type": "chat" + }, + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "display_name": "Kimi K2.5", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 128000 + "context": 262144, + "output": 262144 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "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", + "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." ] } }, "type": "chat" }, { - "id": "claude-opus-4-8-cc", - "name": "claude-opus-4-8-cc", - "display_name": "claude-opus-4-8-cc", + "id": "moonshotai/kimi-k2-instruct", + "name": "Kimi K2 Instruct", + "display_name": "Kimi K2 Instruct", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 100352 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "zai-org/glm-5.1", + "name": "GLM-5.1", + "display_name": "GLM-5.1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 131072 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, + { + "id": "zai-org/glm-5v-turbo", + "name": "GLM-5V-Turbo", + "display_name": "GLM-5V-Turbo", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 131072 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, + { + "id": "baidu/ernie-4.5-vl-424b-a47b", + "name": "ERNIE 4.5 VL 424B A47B", + "display_name": "ERNIE 4.5 VL 424B A47B", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 123000, + "output": 16000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, + { + "id": "meta-llama/llama-3.1-8b-instruct", + "name": "Llama 3.1 8B Instruct", + "display_name": "Llama 3.1 8B Instruct", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 16384, + "output": 16384 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "qwen/qwen3-235b-a22b-instruct-2507", + "name": "Qwen3 235B A22B Instruct 2507", + "display_name": "Qwen3 235B A22B Instruct 2507", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 16384 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "mistralai/mistral-nemo", + "name": "Mistral Nemo", + "display_name": "Mistral Nemo", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 60288, + "output": 32000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "claude-opus-4-8-r", + "name": "claude-opus-4-8-r", + "display_name": "claude-opus-4-8-r", "modalities": { "input": [ "text", @@ -241648,9 +243894,9 @@ "type": "chat" }, { - "id": "claude-opus-4-8-r", - "name": "claude-opus-4-8-r", - "display_name": "claude-opus-4-8-r", + "id": "claude-opus-4-7-r", + "name": "claude-opus-4-7-r", + "display_name": "claude-opus-4-7-r", "modalities": { "input": [ "text", @@ -241698,9 +243944,9 @@ "type": "chat" }, { - "id": "claude-sonnet-4-5-20250929", - "name": "claude-sonnet-4-5-20250929", - "display_name": "claude-sonnet-4-5-20250929", + "id": "claude-opus-4-6-dd", + "name": "claude-opus-4-6-dd", + "display_name": "claude-opus-4-6-dd", "modalities": { "input": [ "text", @@ -241711,8 +243957,8 @@ ] }, "limit": { - "context": 200000, - "output": 64000 + "context": 1000000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -241723,11 +243969,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", @@ -241735,17 +243988,17 @@ "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." ] } }, "type": "chat" }, { - "id": "claude-sonnet-4-5-20250929-dd", - "name": "claude-sonnet-4-5-20250929-dd", - "display_name": "claude-sonnet-4-5-20250929-dd", + "id": "claude-opus-4-6", + "name": "claude-opus-4-6", + "display_name": "claude-opus-4-6", "modalities": { "input": [ "text", @@ -241756,8 +244009,8 @@ ] }, "limit": { - "context": 200000, - "output": 64000 + "context": 1000000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -241768,11 +244021,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", @@ -241780,17 +244040,17 @@ "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." ] } }, "type": "chat" }, { - "id": "claude-sonnet-4-6", - "name": "claude-sonnet-4-6", - "display_name": "claude-sonnet-4-6", + "id": "claude-opus-4-6-r", + "name": "claude-opus-4-6-r", + "display_name": "claude-opus-4-6-r", "modalities": { "input": [ "text", @@ -241840,9 +244100,9 @@ "type": "chat" }, { - "id": "claude-sonnet-4-6-cc", - "name": "claude-sonnet-4-6-cc", - "display_name": "claude-sonnet-4-6-cc", + "id": "claude-sonnet-4-6", + "name": "claude-sonnet-4-6", + "display_name": "claude-sonnet-4-6", "modalities": { "input": [ "text", @@ -241996,9 +244256,9 @@ "type": "chat" }, { - "id": "claude-sonnet-5", - "name": "claude-sonnet-5", - "display_name": "claude-sonnet-5", + "id": "claude-opus-4-5-20251101", + "name": "claude-opus-4-5-20251101", + "display_name": "claude-opus-4-5-20251101", "modalities": { "input": [ "text", @@ -242009,172 +244269,278 @@ ] }, "limit": { - "context": 1000000, - "output": 128000 + "context": 200000, + "output": 65536 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "default_enabled": false, + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, + "effort": "high", + "effort_options": [ + "low", + "medium", + "high" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "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." + ] } }, "type": "chat" }, { - "id": "deepseek/deepseek-r1-0528", - "name": "DeepSeek R1 0528", - "display_name": "DeepSeek R1 0528", + "id": "claude-opus-4-5-20251101-dd", + "name": "claude-opus-4-5-20251101-dd", + "display_name": "claude-opus-4-5-20251101-dd", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 163840, - "output": 32768 + "context": 200000, + "output": 65536 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { "supported": true, + "default_enabled": false, + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, + "effort": "high", + "effort_options": [ + "low", + "medium", + "high" + ], "interleaved": true, "summaries": true, "visibility": "summary", "continuation": [ "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." ] } }, "type": "chat" }, { - "id": "deepseek/deepseek-v3-0324", - "name": "DeepSeek V3 0324", - "display_name": "DeepSeek V3 0324", + "id": "claude-sonnet-4-5-20250929", + "name": "claude-sonnet-4-5-20250929", + "display_name": "claude-sonnet-4-5-20250929", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 163840, - "output": 163840 + "context": 200000, + "output": 64000 }, "tool_call": true, "reasoning": { - "supported": false + "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." + ] + } }, "type": "chat" }, { - "id": "deepseek/deepseek-v3.1", - "name": "DeepSeek V3.1", - "display_name": "DeepSeek V3.1", + "id": "claude-sonnet-4-5-20250929-dd", + "name": "claude-sonnet-4-5-20250929-dd", + "display_name": "claude-sonnet-4-5-20250929-dd", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 163840, - "output": 32768 + "context": 200000, + "output": 64000 }, "tool_call": true, "reasoning": { "supported": true, - "default": 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." + ] + } }, "type": "chat" }, { - "id": "deepseek/deepseek-v4-flash", - "name": "Deepseek V4 Flash", - "display_name": "Deepseek V4 Flash", + "id": "claude-haiku-4-5-20251001", + "name": "claude-haiku-4-5-20251001", + "display_name": "claude-haiku-4-5-20251001", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 393216 + "context": 20000, + "output": 20000 }, "tool_call": true, "reasoning": { "supported": true, - "default": 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." ] } }, "type": "chat" }, { - "id": "deepseek/deepseek-v4-pro", - "name": "Deepseek V4 Pro", - "display_name": "Deepseek V4 Pro", + "id": "claude-haiku-4-5-20251001-dd", + "name": "claude-haiku-4-5-20251001-dd", + "display_name": "claude-haiku-4-5-20251001-dd", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 393216 + "context": 200000, + "output": 64000 }, "tool_call": true, "reasoning": { "supported": true, - "default": 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." ] } }, "type": "chat" }, { - "id": "deepseek/deepseek-ocr-2", - "name": "DeepSeek-OCR 2", - "display_name": "DeepSeek-OCR 2", + "id": "claude-haiku-4-5-20251001-r", + "name": "claude-haiku-4-5-20251001-r", + "display_name": "claude-haiku-4-5-20251001-r", "modalities": { "input": [ "text", @@ -242185,19 +244551,41 @@ ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 200000, + "output": 64000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "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." + ] + } }, "type": "chat" }, { - "id": "doubao-1-5-pro-32k-250115", - "name": "doubao-1-5-pro-32k-250115", - "display_name": "doubao-1-5-pro-32k-250115", + "id": "gpt-5.5-pro", + "name": "gpt-5.5-pro", + "display_name": "gpt-5.5-pro", "modalities": { "input": [ "text", @@ -242208,19 +244596,25 @@ ] }, "limit": { - "context": 128000, - "output": 12000 + "context": 1050000, + "output": 128000 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "type": "chat" }, { - "id": "doubao-1.5-pro-32k-character-250715", - "name": "doubao-1.5-pro-32k-character-250715", - "display_name": "doubao-1.5-pro-32k-character-250715", + "id": "gpt-5.5-light", + "name": "gpt-5.5-light", + "display_name": "gpt-5.5-light", "modalities": { "input": [ "text", @@ -242231,32 +244625,32 @@ ] }, "limit": { - "context": 200000, - "output": 64000 + "context": 1050000, + "output": 128000 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true }, "type": "chat" }, { - "id": "doubao-seed-1-8-251228", - "name": "doubao-seed-1-8-251228", - "display_name": "doubao-seed-1-8-251228", + "id": "gpt-5.5-r", + "name": "gpt-5.5-r", + "display_name": "gpt-5.5-r", "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" ] }, "limit": { - "context": 256000, - "output": 64000 + "context": 1050000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -242266,31 +244660,53 @@ "type": "chat" }, { - "id": "baidu/ernie-4.5-300b-a47b-paddle", - "name": "ERNIE 4.5 300B A47B", - "display_name": "ERNIE 4.5 300B A47B", + "id": "gpt-5.4-pro", + "name": "gpt-5.4-pro", + "display_name": "gpt-5.4-pro", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 123000, - "output": 12000 + "context": 1050000, + "output": 128000 }, "tool_call": true, "reasoning": { - "supported": false + "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" + } }, "type": "chat" }, { - "id": "baidu/ernie-4.5-vl-424b-a47b", - "name": "ERNIE 4.5 VL 424B A47B", - "display_name": "ERNIE 4.5 VL 424B A47B", + "id": "gpt-5.4", + "name": "gpt-5.4", + "display_name": "gpt-5.4", "modalities": { "input": [ "text", @@ -242301,84 +244717,146 @@ ] }, "limit": { - "context": 123000, - "output": 16000 + "context": 1050000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": 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" + } }, "type": "chat" }, { - "id": "gemini-2.0-flash-20250609", - "name": "gemini-2.0-flash-20250609", - "display_name": "gemini-2.0-flash-20250609", + "id": "gpt-5.4-mini", + "name": "gpt-5.4-mini", + "display_name": "gpt-5.4-mini", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 200000 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { - "supported": false + "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" + } }, "type": "chat" }, { - "id": "gemini-2.0-flash-lite", - "name": "gemini-2.0-flash-lite", - "display_name": "gemini-2.0-flash-lite", + "id": "gpt-5.4-nano", + "name": "gpt-5.4-nano", + "display_name": "gpt-5.4-nano", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 8192 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { - "supported": false + "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" + } }, "type": "chat" }, { - "id": "gemini-2.5-flash", - "name": "gemini-2.5-flash", - "display_name": "gemini-2.5-flash", + "id": "gpt-5.3-codex", + "name": "gpt-5.3-codex", + "display_name": "gpt-5.3-codex", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 65535 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -242389,177 +244867,64 @@ "reasoning": { "supported": true, "default_enabled": true, - "mode": "budget", - "budget": { - "default": -1, - "min": 0, - "max": 24576, - "auto": -1, - "off": 0, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "gemini-2.5-flash-lite", - "name": "gemini-2.5-flash-lite", - "display_name": "gemini-2.5-flash-lite", + "id": "gpt-5.3-chat-latest", + "name": "gpt-5.3-chat-latest", + "display_name": "gpt-5.3-chat-latest", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 65535 + "context": 128000, + "output": 16000 }, "tool_call": true, "reasoning": { - "supported": true, - "default": false - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": false, - "mode": "budget", - "budget": { - "default": -1, - "min": 512, - "max": 24576, - "auto": -1, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] - } - }, - "type": "chat" - }, - { - "id": "gemini-2.5-flash-lite-preview-06-17", - "name": "gemini-2.5-flash-lite-preview-06-17", - "display_name": "gemini-2.5-flash-lite-preview-06-17", - "modalities": { - "input": [ - "text", - "video", - "image", - "audio" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 65535 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": false - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": false, - "mode": "budget", - "budget": { - "default": -1, - "min": 512, - "max": 24576, - "auto": -1, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] - } - }, - "type": "chat" - }, - { - "id": "gemini-2.5-flash-lite-preview-09-2025", - "name": "gemini-2.5-flash-lite-preview-09-2025", - "display_name": "gemini-2.5-flash-lite-preview-09-2025", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 65536 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": false - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": false, - "mode": "budget", - "budget": { - "default": -1, - "min": 512, - "max": 24576, - "auto": -1, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] - } + "supported": false }, "type": "chat" }, { - "id": "gemini-2.5-flash-preview-05-20", - "name": "gemini-2.5-flash-preview-05-20", - "display_name": "gemini-2.5-flash-preview-05-20", + "id": "gpt-5.2-pro", + "name": "gpt-5.2-pro", + "display_name": "gpt-5.2-pro", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 200000 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -242570,42 +244935,40 @@ "reasoning": { "supported": true, "default_enabled": true, - "mode": "budget", - "budget": { - "default": -1, - "min": 0, - "max": 24576, - "auto": -1, - "off": 0, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] + "mode": "effort", + "effort": "high", + "effort_options": [ + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "gemini-2.5-pro", - "name": "gemini-2.5-pro", - "display_name": "gemini-2.5-pro", + "id": "gpt-5.2-codex", + "name": "gpt-5.2-codex", + "display_name": "gpt-5.2-codex", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 65535 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -242616,236 +244979,233 @@ "reasoning": { "supported": true, "default_enabled": true, - "mode": "budget", - "budget": { - "default": -1, - "min": 128, - "max": 32768, - "auto": -1, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "gemini-2.5-pro-preview-06-05", - "name": "gemini-2.5-pro-preview-06-05", - "display_name": "gemini-2.5-pro-preview-06-05", + "id": "gpt-5.2-chat-latest", + "name": "gpt-5.2-chat-latest", + "display_name": "gpt-5.2-chat-latest", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 200000 + "context": 128000, + "output": 16000 }, "tool_call": true, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "budget", - "budget": { - "default": -1, - "min": 128, - "max": 32768, - "auto": -1, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] - } + "supported": false }, "type": "chat" }, { - "id": "gemini-3-flash-preview", - "name": "gemini-3-flash-preview", - "display_name": "gemini-3-flash-preview", + "id": "gpt-5.2", + "name": "gpt-5.2", + "display_name": "gpt-5.2", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 65536 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": true, - "mode": "level", - "level": "high", - "level_options": [ - "minimal", + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ "low", "medium", "high" ], - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] + "visibility": "hidden" } }, "type": "chat" }, { - "id": "gemini-3.1-flash-lite-preview", - "name": "gemini-3.1-flash-lite-preview", - "display_name": "gemini-3.1-flash-lite-preview", + "id": "gpt-5.1-codex-max", + "name": "gpt-5.1-codex-max", + "display_name": "gpt-5.1-codex-max", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 65536 + "context": 400000, + "output": 128000 }, "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" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "gemini-3.1-pro-preview", - "name": "gemini-3.1-pro-preview", - "display_name": "gemini-3.1-pro-preview", + "id": "gpt-5.1-codex", + "name": "gpt-5.1-codex", + "display_name": "gpt-5.1-codex", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 65536 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": true, - "mode": "level", - "level": "high", - "level_options": [ + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", "low", + "medium", "high" ], - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "gemini-3.5-flash", - "name": "gemini-3.5-flash", - "display_name": "gemini-3.5-flash", + "id": "gpt-5.1-codex-mini", + "name": "gpt-5.1-codex-mini", + "display_name": "gpt-5.1-codex-mini", "modalities": { "input": [ "text", - "image", - "video", - "audio" + "image" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 65536 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": true, - "mode": "level", - "level": "high", - "level_options": [ - "minimal", + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", "low", "medium", "high" ], - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "google/gemma-3-27b-it", - "name": "Gemma 3 27B", - "display_name": "Gemma 3 27B", + "id": "gpt-5.1-chat-latest", + "name": "gpt-5.1-chat-latest", + "display_name": "gpt-5.1-chat-latest", "modalities": { "input": [ "text", @@ -242856,19 +245216,19 @@ ] }, "limit": { - "context": 32768, - "output": 32768 + "context": 128000, + "output": 16000 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": false }, "type": "chat" }, { - "id": "google/gemma-3-12b-it", - "name": "Gemma3 12B", - "display_name": "Gemma3 12B", + "id": "gpt-5.1", + "name": "gpt-5.1", + "display_name": "gpt-5.1", "modalities": { "input": [ "text", @@ -242879,106 +245239,53 @@ ] }, "limit": { - "context": 131072, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "zai-org/glm-4.5v", - "name": "GLM 4.5V", - "display_name": "GLM 4.5V", - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 65536, - "output": 16384 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "type": "chat" - }, - { - "id": "zai-org/glm-5.2", - "name": "GLM 5.2", - "display_name": "GLM 5.2", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 163840 + "context": 400000, + "output": 128000 }, "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" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "zai-org/glm-4.5", - "name": "GLM-4.5", - "display_name": "GLM-4.5", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 98304 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "type": "chat" - }, - { - "id": "zai-org/glm-4.7", - "name": "GLM-4.7", - "display_name": "GLM-4.7", + "id": "gpt-5-pro", + "name": "gpt-5-pro", + "display_name": "gpt-5-pro", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 204800, - "output": 131072 + "context": 400000, + "output": 272000 }, "tool_call": true, "reasoning": { @@ -242988,30 +245295,35 @@ "extra_capabilities": { "reasoning": { "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] + "default_enabled": true, + "mode": "fixed", + "effort": "high", + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "zai-org/glm-4.7-flash", - "name": "GLM-4.7-Flash", - "display_name": "GLM-4.7-Flash", + "id": "gpt-5-codex", + "name": "gpt-5-codex", + "display_name": "gpt-5-codex", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 200000, + "context": 400000, "output": 128000 }, "tool_call": true, @@ -243021,60 +245333,66 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "minimal", + "low", + "medium", + "high" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "zai-org/glm-5", - "name": "GLM-5", - "display_name": "GLM-5", + "id": "gpt-5-chat-latest", + "name": "gpt-5-chat-latest", + "display_name": "gpt-5-chat-latest", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 204800, - "output": 131072 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "type": "chat" }, { - "id": "zai-org/glm-5-turbo", - "name": "GLM-5-Turbo", - "display_name": "GLM-5-Turbo", + "id": "gpt-5", + "name": "gpt-5", + "display_name": "gpt-5", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 202800, - "output": 131072 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -243083,26 +245401,43 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "minimal", + "low", + "medium", + "high" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "zai-org/glm-5.1", - "name": "GLM-5.1", - "display_name": "GLM-5.1", + "id": "gpt-5-mini", + "name": "gpt-5-mini", + "display_name": "gpt-5-mini", "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] }, "limit": { - "context": 204800, - "output": 131072 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { @@ -243111,56 +245446,69 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "minimal", + "low", + "medium", + "high" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" } }, "type": "chat" }, { - "id": "zai-org/glm-5v-turbo", - "name": "GLM-5V-Turbo", - "display_name": "GLM-5V-Turbo", + "id": "gpt-5-nano", + "name": "gpt-5-nano", + "display_name": "gpt-5-nano", "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" ] }, "limit": { - "context": 204800, - "output": 131072 + "context": 400000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, "default": true }, - "type": "chat" - }, - { - "id": "zai-org/glm-ocr", - "name": "GLM-OCR", - "display_name": "GLM-OCR", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 32000, - "output": 32000 - }, - "tool_call": false, - "reasoning": { - "supported": false + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "minimal", + "low", + "medium", + "high" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } }, "type": "chat" }, @@ -243211,9 +245559,9 @@ "type": "chat" }, { - "id": "gpt-4.1-nano", - "name": "gpt-4.1-nano", - "display_name": "gpt-4.1-nano", + "id": "gpt-4o", + "name": "gpt-4o", + "display_name": "gpt-4o", "modalities": { "input": [ "text", @@ -243224,8 +245572,8 @@ ] }, "limit": { - "context": 1047576, - "output": 32768 + "context": 131072, + "output": 131072 }, "tool_call": true, "reasoning": { @@ -243234,9 +245582,9 @@ "type": "chat" }, { - "id": "gpt-4o", - "name": "gpt-4o", - "display_name": "gpt-4o", + "id": "gpt-4o-mini", + "name": "gpt-4o-mini", + "display_name": "gpt-4o-mini", "modalities": { "input": [ "text", @@ -243247,8 +245595,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 128000, + "output": 16384 }, "tool_call": true, "reasoning": { @@ -243257,9 +245605,9 @@ "type": "chat" }, { - "id": "gpt-4o-mini", - "name": "gpt-4o-mini", - "display_name": "gpt-4o-mini", + "id": "openai/gpt-oss-20b", + "name": "OpenAI: GPT OSS 20B", + "display_name": "OpenAI: GPT OSS 20B", "modalities": { "input": [ "text", @@ -243270,19 +245618,25 @@ ] }, "limit": { - "context": 128000, - "output": 16384 + "context": 131072, + "output": 32768 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "type": "chat" }, { - "id": "gpt-5", - "name": "gpt-5", - "display_name": "gpt-5", + "id": "o1", + "name": "o1", + "display_name": "o1", "modalities": { "input": [ "text", @@ -243293,8 +245647,8 @@ ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 200000, + "output": 100000 }, "tool_call": true, "reasoning": { @@ -243308,13 +245662,6 @@ "mode": "effort", "effort": "medium", "effort_options": [ - "minimal", - "low", - "medium", - "high" - ], - "verbosity": "medium", - "verbosity_options": [ "low", "medium", "high" @@ -243325,9 +245672,9 @@ "type": "chat" }, { - "id": "gpt-5-chat-latest", - "name": "gpt-5-chat-latest", - "display_name": "gpt-5-chat-latest", + "id": "o3", + "name": "o3", + "display_name": "o3", "modalities": { "input": [ "text", @@ -243338,19 +245685,34 @@ ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 200000, + "output": 100000 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } }, "type": "chat" }, { - "id": "gpt-5-codex", - "name": "gpt-5-codex", - "display_name": "gpt-5-codex", + "id": "o1-mini", + "name": "o1-mini", + "display_name": "o1-mini", "modalities": { "input": [ "text", @@ -243361,8 +245723,8 @@ ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 128000, + "output": 65536 }, "tool_call": true, "reasoning": { @@ -243376,13 +245738,6 @@ "mode": "effort", "effort": "medium", "effort_options": [ - "minimal", - "low", - "medium", - "high" - ], - "verbosity": "medium", - "verbosity_options": [ "low", "medium", "high" @@ -243393,9 +245748,9 @@ "type": "chat" }, { - "id": "gpt-5-mini", - "name": "gpt-5-mini", - "display_name": "gpt-5-mini", + "id": "o3-mini", + "name": "o3-mini", + "display_name": "o3-mini", "modalities": { "input": [ "text", @@ -243406,8 +245761,8 @@ ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 200000, + "output": 100000 }, "tool_call": true, "reasoning": { @@ -243421,13 +245776,6 @@ "mode": "effort", "effort": "medium", "effort_options": [ - "minimal", - "low", - "medium", - "high" - ], - "verbosity": "medium", - "verbosity_options": [ "low", "medium", "high" @@ -243438,21 +245786,23 @@ "type": "chat" }, { - "id": "gpt-5-nano", - "name": "gpt-5-nano", - "display_name": "gpt-5-nano", + "id": "gemini-3.1-flash-lite-preview", + "name": "gemini-3.1-flash-lite-preview", + "display_name": "gemini-3.1-flash-lite-preview", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 1048576, + "output": 65536 }, "tool_call": true, "reasoning": { @@ -243461,31 +245811,15 @@ }, "extra_capabilities": { "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "minimal", - "low", - "medium", - "high" - ], - "verbosity": "medium", - "verbosity_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" + "supported": true } }, "type": "chat" }, { - "id": "gpt-5-pro", - "name": "gpt-5-pro", - "display_name": "gpt-5-pro", + "id": "openai/gpt-oss-120b", + "name": "OpenAI GPT OSS 120B", + "display_name": "OpenAI GPT OSS 120B", "modalities": { "input": [ "text", @@ -243496,8 +245830,8 @@ ] }, "limit": { - "context": 400000, - "output": 272000 + "context": 131072, + "output": 131072 }, "tool_call": true, "reasoning": { @@ -243506,183 +245840,196 @@ }, "extra_capabilities": { "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "fixed", - "effort": "high", - "verbosity": "medium", - "verbosity_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" + "supported": true } }, "type": "chat" }, { - "id": "gpt-5.1", - "name": "gpt-5.1", - "display_name": "gpt-5.1", + "id": "gemini-3-flash-preview", + "name": "gemini-3-flash-preview", + "display_name": "gemini-3-flash-preview", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 1048576, + "output": 65536 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": false, - "mode": "effort", - "effort": "none", - "effort_options": [ - "none", - "low", - "medium", - "high" - ], - "verbosity": "medium", - "verbosity_options": [ + "default_enabled": true, + "mode": "level", + "level": "high", + "level_options": [ + "minimal", "low", "medium", "high" ], - "visibility": "hidden" + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] } }, "type": "chat" }, { - "id": "gpt-5.1-chat-latest", - "name": "gpt-5.1-chat-latest", - "display_name": "gpt-5.1-chat-latest", + "id": "gemini-2.5-pro", + "name": "gemini-2.5-pro", + "display_name": "gemini-2.5-pro", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 128000, - "output": 16000 + "context": 1048576, + "output": 65535 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "budget", + "budget": { + "default": -1, + "min": 128, + "max": 32768, + "auto": -1, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } }, "type": "chat" }, { - "id": "gpt-5.1-codex", - "name": "gpt-5.1-codex", - "display_name": "gpt-5.1-codex", + "id": "gemini-2.5-pro-preview-06-05", + "name": "gemini-2.5-pro-preview-06-05", + "display_name": "gemini-2.5-pro-preview-06-05", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 1048576, + "output": 200000 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "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" + "default_enabled": true, + "mode": "budget", + "budget": { + "default": -1, + "min": 128, + "max": 32768, + "auto": -1, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] } }, "type": "chat" }, { - "id": "gpt-5.1-codex-max", - "name": "gpt-5.1-codex-max", - "display_name": "gpt-5.1-codex-max", + "id": "gemini-2.5-flash-preview-05-20", + "name": "gemini-2.5-flash-preview-05-20", + "display_name": "gemini-2.5-flash-preview-05-20", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 1048576, + "output": 200000 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "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" + "default_enabled": true, + "mode": "budget", + "budget": { + "default": -1, + "min": 0, + "max": 24576, + "auto": -1, + "off": 0, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] } }, "type": "chat" }, { - "id": "gpt-5.1-codex-mini", - "name": "gpt-5.1-codex-mini", - "display_name": "gpt-5.1-codex-mini", + "id": "grok-4.20-multi-agent-0309", + "name": "grok-4.20-multi-agent-0309", + "display_name": "grok-4.20-multi-agent-0309", "modalities": { "input": [ "text", @@ -243693,211 +246040,245 @@ ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 2000000, + "output": 2000000 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "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" + "supported": true } }, "type": "chat" }, { - "id": "gpt-5.2", - "name": "gpt-5.2", - "display_name": "gpt-5.2", + "id": "gemini-2.5-flash", + "name": "gemini-2.5-flash", + "display_name": "gemini-2.5-flash", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 1048576, + "output": 65535 }, "tool_call": true, "reasoning": { "supported": true, - "default": false + "default": true }, "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" + "default_enabled": true, + "mode": "budget", + "budget": { + "default": -1, + "min": 0, + "max": 24576, + "auto": -1, + "off": 0, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] } }, "type": "chat" }, { - "id": "gpt-5.2-chat-latest", - "name": "gpt-5.2-chat-latest", - "display_name": "gpt-5.2-chat-latest", + "id": "gemini-2.5-flash-lite-preview-06-17", + "name": "gemini-2.5-flash-lite-preview-06-17", + "display_name": "gemini-2.5-flash-lite-preview-06-17", "modalities": { "input": [ "text", - "image" + "video", + "image", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 128000, - "output": 16000 + "context": 1048576, + "output": 65535 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "budget", + "budget": { + "default": -1, + "min": 512, + "max": 24576, + "auto": -1, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } }, "type": "chat" }, { - "id": "gpt-5.2-codex", - "name": "gpt-5.2-codex", - "display_name": "gpt-5.2-codex", + "id": "gemini-2.5-flash-lite-preview-09-2025", + "name": "gemini-2.5-flash-lite-preview-09-2025", + "display_name": "gemini-2.5-flash-lite-preview-09-2025", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 1048576, + "output": 65536 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "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" + "default_enabled": false, + "mode": "budget", + "budget": { + "default": -1, + "min": 512, + "max": 24576, + "auto": -1, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] } }, "type": "chat" }, { - "id": "gpt-5.2-pro", - "name": "gpt-5.2-pro", - "display_name": "gpt-5.2-pro", + "id": "gemini-2.5-flash-lite", + "name": "gemini-2.5-flash-lite", + "display_name": "gemini-2.5-flash-lite", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 1048576, + "output": 65535 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "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" + "default_enabled": false, + "mode": "budget", + "budget": { + "default": -1, + "min": 512, + "max": 24576, + "auto": -1, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] } }, "type": "chat" }, { - "id": "gpt-5.3-chat-latest", - "name": "gpt-5.3-chat-latest", - "display_name": "gpt-5.3-chat-latest", + "id": "gemini-2.0-flash-20250609", + "name": "gemini-2.0-flash-20250609", + "display_name": "gemini-2.0-flash-20250609", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 200000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "gemini-2.0-flash-lite", + "name": "gemini-2.0-flash-lite", + "display_name": "gemini-2.0-flash-lite", "modalities": { "input": [ "text", - "image" + "image", + "video", + "audio" ], "output": [ "text" ] }, "limit": { - "context": 128000, - "output": 16000 + "context": 1048576, + "output": 8192 }, "tool_call": true, "reasoning": { @@ -243906,9 +246287,9 @@ "type": "chat" }, { - "id": "gpt-5.3-codex", - "name": "gpt-5.3-codex", - "display_name": "gpt-5.3-codex", + "id": "google/gemma-3-27b-it", + "name": "Gemma 3 27B", + "display_name": "Gemma 3 27B", "modalities": { "input": [ "text", @@ -243919,41 +246300,19 @@ ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 32768, + "output": 32768 }, - "tool_call": true, + "tool_call": false, "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" - } + "supported": false }, "type": "chat" }, { - "id": "gpt-5.4", - "name": "gpt-5.4", - "display_name": "gpt-5.4", + "id": "google/gemma-3-12b-it", + "name": "Gemma3 12B", + "display_name": "Gemma3 12B", "modalities": { "input": [ "text", @@ -243964,42 +246323,19 @@ ] }, "limit": { - "context": 1050000, - "output": 128000 + "context": 131072, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "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" - } + "supported": false }, "type": "chat" }, { - "id": "gpt-5.4-mini", - "name": "gpt-5.4-mini", - "display_name": "gpt-5.4-mini", + "id": "grok-4-0709", + "name": "grok-4-0709", + "display_name": "grok-4-0709", "modalities": { "input": [ "text", @@ -244010,42 +246346,19 @@ ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 256000, + "output": 256000 }, "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" - } + "supported": false }, "type": "chat" }, { - "id": "gpt-5.4-nano", - "name": "gpt-5.4-nano", - "display_name": "gpt-5.4-nano", + "id": "grok-4.20-0309-reasoning", + "name": "grok-4.20-0309-reasoning", + "display_name": "grok-4.20-0309-reasoning", "modalities": { "input": [ "text", @@ -244056,42 +246369,20 @@ ] }, "limit": { - "context": 400000, - "output": 128000 + "context": 2000000, + "output": 2000000 }, "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" - } + "default": true }, "type": "chat" }, { - "id": "gpt-5.4-pro", - "name": "gpt-5.4-pro", - "display_name": "gpt-5.4-pro", + "id": "grok-4.20-0309-non-reasoning", + "name": "grok-4.20-0309-non-reasoning", + "display_name": "grok-4.20-0309-non-reasoning", "modalities": { "input": [ "text", @@ -244102,40 +246393,19 @@ ] }, "limit": { - "context": 1050000, - "output": 128000 + "context": 2000000, + "output": 2000000 }, "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" - } + "supported": false }, "type": "chat" }, { - "id": "gpt-5.5", - "name": "gpt-5.5", - "display_name": "gpt-5.5", + "id": "grok-4.3", + "name": "grok-4.3", + "display_name": "grok-4.3", "modalities": { "input": [ "text", @@ -244146,8 +246416,8 @@ ] }, "limit": { - "context": 1050000, - "output": 128000 + "context": 1000000, + "output": 1000000 }, "tool_call": true, "reasoning": { @@ -244156,31 +246426,15 @@ }, "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" + "supported": true } }, "type": "chat" }, { - "id": "gpt-5.5-light", - "name": "gpt-5.5-light", - "display_name": "gpt-5.5-light", + "id": "grok-4-1-fast-reasoning", + "name": "grok-4-1-fast-reasoning", + "display_name": "grok-4-1-fast-reasoning", "modalities": { "input": [ "text", @@ -244191,20 +246445,25 @@ ] }, "limit": { - "context": 1050000, - "output": 128000 + "context": 2000000, + "output": 2000000 }, "tool_call": true, "reasoning": { "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, "type": "chat" }, { - "id": "gpt-5.5-pro", - "name": "gpt-5.5-pro", - "display_name": "gpt-5.5-pro", + "id": "grok-4-1-fast-non-reasoning", + "name": "grok-4-1-fast-non-reasoning", + "display_name": "grok-4-1-fast-non-reasoning", "modalities": { "input": [ "text", @@ -244215,25 +246474,19 @@ ] }, "limit": { - "context": 1050000, - "output": 128000 + "context": 2000000, + "output": 2000000 }, "tool_call": true, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "type": "chat" }, { - "id": "gpt-5.5-r", - "name": "gpt-5.5-r", - "display_name": "gpt-5.5-r", + "id": "grok-4-fast-non-reasoning", + "name": "grok-4-fast-non-reasoning", + "display_name": "grok-4-fast-non-reasoning", "modalities": { "input": [ "text", @@ -244244,13 +246497,12 @@ ] }, "limit": { - "context": 1050000, - "output": 128000 + "context": 2000000, + "output": 2000000 }, "tool_call": true, "reasoning": { - "supported": true, - "default": true + "supported": false }, "type": "chat" }, @@ -244302,9 +246554,9 @@ "type": "chat" }, { - "id": "grok-4-0709", - "name": "grok-4-0709", - "display_name": "grok-4-0709", + "id": "grok-code-fast-1", + "name": "grok-code-fast-1", + "display_name": "grok-code-fast-1", "modalities": { "input": [ "text", @@ -244320,26 +246572,65 @@ }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "type": "chat" }, { - "id": "grok-4-1-fast-non-reasoning", - "name": "grok-4-1-fast-non-reasoning", - "display_name": "grok-4-1-fast-non-reasoning", + "id": "deepseek/deepseek-r1-0528", + "name": "DeepSeek R1 0528", + "display_name": "DeepSeek R1 0528", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 163840, + "output": 32768 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "type": "chat" + }, + { + "id": "deepseek/deepseek-v3-0324", + "name": "DeepSeek V3 0324", + "display_name": "DeepSeek V3 0324", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 163840, + "output": 163840 }, "tool_call": true, "reasoning": { @@ -244348,38 +246639,32 @@ "type": "chat" }, { - "id": "grok-4-1-fast-reasoning", - "name": "grok-4-1-fast-reasoning", - "display_name": "grok-4-1-fast-reasoning", + "id": "deepseek/deepseek-v3.1", + "name": "DeepSeek V3.1", + "display_name": "DeepSeek V3.1", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 163840, + "output": 32768 }, "tool_call": true, "reasoning": { "supported": true, "default": true }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, "type": "chat" }, { - "id": "grok-4-fast-non-reasoning", - "name": "grok-4-fast-non-reasoning", - "display_name": "grok-4-fast-non-reasoning", + "id": "deepseek/deepseek-ocr-2", + "name": "DeepSeek-OCR 2", + "display_name": "DeepSeek-OCR 2", "modalities": { "input": [ "text", @@ -244390,19 +246675,19 @@ ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": false }, "type": "chat" }, { - "id": "grok-4-fast-reasoning", - "name": "grok-4-fast-reasoning", - "display_name": "grok-4-fast-reasoning", + "id": "doubao-1-5-pro-32k-250115", + "name": "doubao-1-5-pro-32k-250115", + "display_name": "doubao-1-5-pro-32k-250115", "modalities": { "input": [ "text", @@ -244413,24 +246698,19 @@ ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 128000, + "output": 12000 }, "tool_call": true, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "type": "chat" }, { - "id": "grok-4.20-0309-non-reasoning", - "name": "grok-4.20-0309-non-reasoning", - "display_name": "grok-4.20-0309-non-reasoning", + "id": "doubao-1.5-pro-32k-character-250715", + "name": "doubao-1.5-pro-32k-character-250715", + "display_name": "doubao-1.5-pro-32k-character-250715", "modalities": { "input": [ "text", @@ -244441,8 +246721,8 @@ ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 200000, + "output": 64000 }, "tool_call": true, "reasoning": { @@ -244451,21 +246731,22 @@ "type": "chat" }, { - "id": "grok-4.20-0309-reasoning", - "name": "grok-4.20-0309-reasoning", - "display_name": "grok-4.20-0309-reasoning", + "id": "doubao-seed-1-8-251228", + "name": "doubao-seed-1-8-251228", + "display_name": "doubao-seed-1-8-251228", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 256000, + "output": 64000 }, "tool_call": true, "reasoning": { @@ -244475,79 +246756,92 @@ "type": "chat" }, { - "id": "grok-4.20-multi-agent-0309", - "name": "grok-4.20-multi-agent-0309", - "display_name": "grok-4.20-multi-agent-0309", + "id": "baidu/ernie-4.5-300b-a47b-paddle", + "name": "ERNIE 4.5 300B A47B", + "display_name": "ERNIE 4.5 300B A47B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 123000, + "output": 12000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "zai-org/glm-4.5v", + "name": "GLM 4.5V", + "display_name": "GLM 4.5V", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 65536, + "output": 16384 }, "tool_call": true, "reasoning": { "supported": true, "default": true }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, "type": "chat" }, { - "id": "grok-4.3", - "name": "grok-4.3", - "display_name": "grok-4.3", + "id": "zai-org/glm-4.5", + "name": "GLM-4.5", + "display_name": "GLM-4.5", "modalities": { "input": [ - "text", - "image" + "text" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 1000000 + "context": 131072, + "output": 98304 }, "tool_call": true, "reasoning": { "supported": true, "default": true }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, "type": "chat" }, { - "id": "grok-code-fast-1", - "name": "grok-code-fast-1", - "display_name": "grok-code-fast-1", + "id": "moonshotai/kimi-k2.6", + "name": "Kimi K2.6", + "display_name": "Kimi K2.6", "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 262144, + "output": 262144 }, "tool_call": true, "reasoning": { @@ -244562,9 +246856,9 @@ "type": "chat" }, { - "id": "moonshotai/kimi-k2-0905", - "name": "Kimi K2 0905", - "display_name": "Kimi K2 0905", + "id": "zai-org/glm-4.7", + "name": "GLM-4.7", + "display_name": "GLM-4.7", "modalities": { "input": [ "text" @@ -244574,19 +246868,31 @@ ] }, "limit": { - "context": 262144, - "output": 100352 + "context": 204800, + "output": 131072 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "type": "chat" }, { - "id": "moonshotai/kimi-k2-instruct", - "name": "Kimi K2 Instruct", - "display_name": "Kimi K2 Instruct", + "id": "zai-org/glm-4.7-flash", + "name": "GLM-4.7-Flash", + "display_name": "GLM-4.7-Flash", "modalities": { "input": [ "text" @@ -244596,32 +246902,36 @@ ] }, "limit": { - "context": 131072, - "output": 100352 + "context": 200000, + "output": 128000 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "type": "chat" }, { - "id": "moonshotai/kimi-k2.5", - "name": "Kimi K2.5", - "display_name": "Kimi K2.5", + "id": "zai-org/glm-5", + "name": "GLM-5", + "display_name": "GLM-5", "modalities": { "input": [ - "text", - "image", - "video" + "text" ], "output": [ "text" ] }, "limit": { - "context": 262144, - "output": 262144 + "context": 204800, + "output": 131072 }, "tool_call": true, "reasoning": { @@ -244642,22 +246952,20 @@ "type": "chat" }, { - "id": "moonshotai/kimi-k2.6", - "name": "Kimi K2.6", - "display_name": "Kimi K2.6", + "id": "zai-org/glm-5-turbo", + "name": "GLM-5-Turbo", + "display_name": "GLM-5-Turbo", "modalities": { "input": [ - "text", - "image", - "video" + "text" ], "output": [ "text" ] }, "limit": { - "context": 262144, - "output": 262144 + "context": 202800, + "output": 131072 }, "tool_call": true, "reasoning": { @@ -244672,39 +246980,32 @@ "type": "chat" }, { - "id": "moonshotai/kimi-k2.7-code", - "name": "Kimi K2.7 Code", - "display_name": "Kimi K2.7 Code", + "id": "zai-org/glm-ocr", + "name": "GLM-OCR", + "display_name": "GLM-OCR", "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" ] }, "limit": { - "context": 262144, - "output": 262144 + "context": 32000, + "output": 32000 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "type": "chat" }, { - "id": "sao10k/l3-70b-euryale-v2.1", - "name": "L3 70B Euryale V2.1", - "display_name": "L3 70B Euryale V2.1", + "id": "moonshotai/kimi-k2-0905", + "name": "Kimi K2 0905", + "display_name": "Kimi K2 0905", "modalities": { "input": [ "text" @@ -244714,8 +247015,8 @@ ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 262144, + "output": 100352 }, "tool_call": true, "reasoning": { @@ -244746,9 +247047,9 @@ "type": "chat" }, { - "id": "sao10k/l31-70b-euryale-v2.2", - "name": "L31 70B Euryale V2.2", - "display_name": "L31 70B Euryale V2.2", + "id": "sao10k/l3-70b-euryale-v2.1", + "name": "L3 70B Euryale V2.1", + "display_name": "L3 70B Euryale V2.1", "modalities": { "input": [ "text" @@ -244768,9 +247069,9 @@ "type": "chat" }, { - "id": "inclusionai/ling-2.6-flash", - "name": "Ling-2.6-flash", - "display_name": "Ling-2.6-flash", + "id": "sao10k/l31-70b-euryale-v2.2", + "name": "L31 70B Euryale V2.2", + "display_name": "L31 70B Euryale V2.2", "modalities": { "input": [ "text" @@ -244780,8 +247081,8 @@ ] }, "limit": { - "context": 262144, - "output": 32768 + "context": 8192, + "output": 8192 }, "tool_call": true, "reasoning": { @@ -244790,9 +247091,9 @@ "type": "chat" }, { - "id": "meta-llama/llama-3.1-8b-instruct", - "name": "Llama 3.1 8B Instruct", - "display_name": "Llama 3.1 8B Instruct", + "id": "inclusionai/ling-2.6-flash", + "name": "Ling-2.6-flash", + "display_name": "Ling-2.6-flash", "modalities": { "input": [ "text" @@ -244802,10 +247103,10 @@ ] }, "limit": { - "context": 16384, - "output": 16384 + "context": 262144, + "output": 32768 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": false }, @@ -244980,68 +247281,6 @@ }, "type": "chat" }, - { - "id": "minimax/minimax-m2.5-highspeed", - "name": "MiniMax M2.5-highspeed", - "display_name": "MiniMax M2.5-highspeed", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 131100 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "type": "chat" - }, - { - "id": "minimax/minimax-m2.7", - "name": "MiniMax M2.7", - "display_name": "MiniMax M2.7", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 131100 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "type": "chat" - }, { "id": "minimax/minimax-m2.7-highspeed", "name": "MiniMax M2.7-highspeed", @@ -245077,22 +247316,20 @@ "type": "chat" }, { - "id": "minimax/minimax-m3", - "name": "MiniMax M3", - "display_name": "MiniMax M3", + "id": "minimax/minimax-m2.5-highspeed", + "name": "MiniMax M2.5-highspeed", + "display_name": "MiniMax M2.5-highspeed", "modalities": { "input": [ - "text", - "image", - "video" + "text" ], "output": [ "text" ] }, "limit": { - "context": 1000000, - "output": 131072 + "context": 204800, + "output": 131100 }, "tool_call": true, "reasoning": { @@ -245128,28 +247365,6 @@ }, "type": "chat" }, - { - "id": "mistralai/mistral-nemo", - "name": "Mistral Nemo", - "display_name": "Mistral Nemo", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 60288, - "output": 32000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "gryphe/mythomax-l2-13b", "name": "Mythomax L2 13B", @@ -245195,216 +247410,6 @@ }, "type": "chat" }, - { - "id": "o1", - "name": "o1", - "display_name": "o1", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 200000, - "output": 100000 - }, - "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" - } - }, - "type": "chat" - }, - { - "id": "o1-mini", - "name": "o1-mini", - "display_name": "o1-mini", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 128000, - "output": 65536 - }, - "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" - } - }, - "type": "chat" - }, - { - "id": "o3", - "name": "o3", - "display_name": "o3", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 200000, - "output": 100000 - }, - "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" - } - }, - "type": "chat" - }, - { - "id": "o3-mini", - "name": "o3-mini", - "display_name": "o3-mini", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 200000, - "output": 100000 - }, - "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" - } - }, - "type": "chat" - }, - { - "id": "openai/gpt-oss-120b", - "name": "OpenAI GPT OSS 120B", - "display_name": "OpenAI GPT OSS 120B", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 131072 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "type": "chat" - }, - { - "id": "openai/gpt-oss-20b", - "name": "OpenAI: GPT OSS 20B", - "display_name": "OpenAI: GPT OSS 20B", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 32768 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "type": "chat" - }, { "id": "qwen/qwen-2.5-72b-instruct", "name": "Qwen 2.5 72B Instruct", @@ -245496,9 +247501,9 @@ "type": "chat" }, { - "id": "qwen/qwen3-235b-a22b-fp8", - "name": "Qwen3 235B A22B", - "display_name": "Qwen3 235B A22B", + "id": "qwen/qwen3-30b-a3b-fp8", + "name": "Qwen3 30B A3B", + "display_name": "Qwen3 30B A3B", "modalities": { "input": [ "text" @@ -245519,31 +247524,9 @@ "type": "chat" }, { - "id": "qwen/qwen3-235b-a22b-instruct-2507", - "name": "Qwen3 235B A22B Instruct 2507", - "display_name": "Qwen3 235B A22B Instruct 2507", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 16384 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, - { - "id": "qwen/qwen3-235b-a22b-thinking-2507", - "name": "Qwen3 235B A22b Thinking 2507", - "display_name": "Qwen3 235B A22b Thinking 2507", + "id": "qwen/qwen3-32b-fp8", + "name": "Qwen3 32B", + "display_name": "Qwen3 32B", "modalities": { "input": [ "text" @@ -245553,31 +247536,20 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 40960, + "output": 20000 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": true, "default": true }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, "type": "chat" }, { - "id": "qwen/qwen3-30b-a3b-fp8", - "name": "Qwen3 30B A3B", - "display_name": "Qwen3 30B A3B", + "id": "qwen/qwen3-235b-a22b-fp8", + "name": "Qwen3 235B A22B", + "display_name": "Qwen3 235B A22B", "modalities": { "input": [ "text" @@ -245598,9 +247570,9 @@ "type": "chat" }, { - "id": "qwen/qwen3-32b-fp8", - "name": "Qwen3 32B", - "display_name": "Qwen3 32B", + "id": "qwen/qwen3-235b-a22b-thinking-2507", + "name": "Qwen3 235B A22b Thinking 2507", + "display_name": "Qwen3 235B A22b Thinking 2507", "modalities": { "input": [ "text" @@ -245610,14 +247582,25 @@ ] }, "limit": { - "context": 40960, - "output": 20000 + "context": 131072, + "output": 131072 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, "type": "chat" }, { @@ -245721,9 +247704,32 @@ "type": "chat" }, { - "id": "qwen/qwen3.5-122b-a10b", - "name": "Qwen3.5-122B-A10B", - "display_name": "Qwen3.5-122B-A10B", + "id": "xiaomimimo/mimo-v2-pro", + "name": "XiaomiMiMo/MiMo-V2-Pro", + "display_name": "XiaomiMiMo/MiMo-V2-Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, + { + "id": "qwen/qwen3.5-27b", + "name": "Qwen3.5-27B", + "display_name": "Qwen3.5-27B", "modalities": { "input": [ "text", @@ -245757,122 +247763,310 @@ "type": "chat" }, { - "id": "qwen/qwen3.5-27b", - "name": "Qwen3.5-27B", - "display_name": "Qwen3.5-27B", + "id": "gemini-3.1-flash-image", + "name": "gemini-3.1-flash-image", + "display_name": "gemini-3.1-flash-image", "modalities": { "input": [ "text", "image", - "video" + "video", + "audio" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "imageGeneration" + }, + { + "id": "gemini-3-pro-image", + "name": "gemini-3-pro-image", + "display_name": "gemini-3-pro-image", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "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" + ] + } + }, + "type": "imageGeneration" + }, + { + "id": "gemini-2.5-flash-image", + "name": "gemini-2.5-flash-image", + "display_name": "gemini-2.5-flash-image", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "imageGeneration" + }, + { + "id": "gpt-image-2", + "name": "gpt-image-2", + "display_name": "gpt-image-2", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "imageGeneration" + }, + { + "id": "slow-mock", + "name": "slow-mock", + "display_name": "slow-mock", + "modalities": { + "input": [ + "text" ], "output": [ "text" ] }, "limit": { - "context": 262144, - "output": 65500 + "context": 128000, + "output": 128000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "chat" + }, + { + "id": "claude-opus-4-8-cc", + "name": "claude-opus-4-8-cc", + "display_name": "claude-opus-4-8-cc", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": 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": "summary", + "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." ] } }, "type": "chat" }, { - "id": "qwen/qwen3.5-35b-a3b", - "name": "Qwen3.5-35B-A3B", - "display_name": "Qwen3.5-35B-A3B", + "id": "claude-opus-4-6-cc", + "name": "claude-opus-4-6-cc", + "display_name": "claude-opus-4-6-cc", "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" ] }, "limit": { - "context": 262144, - "output": 65500 + "context": 1000000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "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." ] } }, "type": "chat" }, { - "id": "qwen/qwen3.5-397b-a17b", - "name": "Qwen3.5-397B-A17B", - "display_name": "Qwen3.5-397B-A17B", + "id": "claude-opus-4-7-cc", + "name": "claude-opus-4-7-cc", + "display_name": "claude-opus-4-7-cc", "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" ] }, "limit": { - "context": 262144, - "output": 64000 + "context": 1000000, + "output": 128000 }, "tool_call": true, "reasoning": { "supported": true, - "default": 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": "summary", + "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." ] } }, "type": "chat" }, { - "id": "qwen/qwen3.5-plus", - "name": "Qwen3.5-Plus", - "display_name": "Qwen3.5-Plus", + "id": "claude-sonnet-4-6-cc", + "name": "claude-sonnet-4-6-cc", + "display_name": "claude-sonnet-4-6-cc", "modalities": { "input": [ "text", - "image", - "video" + "image" ], "output": [ "text" @@ -245880,26 +248074,111 @@ }, "limit": { "context": 1000000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "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." + ] + } + }, + "type": "chat" + }, + { + "id": "claude-haiku-4-5-20251001-cc", + "name": "claude-haiku-4-5-20251001-cc", + "display_name": "claude-haiku-4-5-20251001-cc", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, "output": 64000 }, "tool_call": true, "reasoning": { "supported": true, - "default": 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." ] } }, "type": "chat" }, + { + "id": "xiaomimimo/mimo-v2.5-pro", + "name": "XiaomiMiMo/MiMo-V2.5-Pro", + "display_name": "XiaomiMiMo/MiMo-V2.5-Pro", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 131072 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, { "id": "inclusionai/ring-2.6-1t", "name": "Ring-2.6-1T", @@ -245924,34 +248203,50 @@ "type": "chat" }, { - "id": "sao10k/l3-8b-lunaris", - "name": "Sao10k L3 8B Lunaris", - "display_name": "Sao10k L3 8B Lunaris", + "id": "qwen/qwen3.5-122b-a10b", + "name": "Qwen3.5-122B-A10B", + "display_name": "Qwen3.5-122B-A10B", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 262144, + "output": 65500 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "type": "chat" }, { - "id": "xiaomimimo/mimo-v2-flash", - "name": "XiaomiMiMo/MiMo-V2-Flash", - "display_name": "XiaomiMiMo/MiMo-V2-Flash", + "id": "qwen/qwen3.5-35b-a3b", + "name": "Qwen3.5-35B-A3B", + "display_name": "Qwen3.5-35B-A3B", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -245959,42 +248254,102 @@ }, "limit": { "context": 262144, - "output": 131072 + "output": 65500 }, "tool_call": true, "reasoning": { "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, "type": "chat" }, { - "id": "xiaomimimo/mimo-v2-pro", - "name": "XiaomiMiMo/MiMo-V2-Pro", - "display_name": "XiaomiMiMo/MiMo-V2-Pro", + "id": "qwen/qwen3.5-397b-a17b", + "name": "Qwen3.5-397B-A17B", + "display_name": "Qwen3.5-397B-A17B", "modalities": { "input": [ + "text", + "image", + "video" + ], + "output": [ "text" + ] + }, + "limit": { + "context": 262144, + "output": 64000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "type": "chat" + }, + { + "id": "qwen/qwen3.5-plus", + "name": "Qwen3.5-Plus", + "display_name": "Qwen3.5-Plus", + "modalities": { + "input": [ + "text", + "image", + "video" ], "output": [ "text" ] }, "limit": { - "context": 1048576, - "output": 131072 + "context": 1000000, + "output": 64000 }, "tool_call": true, "reasoning": { "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, "type": "chat" }, { - "id": "xiaomimimo/mimo-v2.5-pro", - "name": "XiaomiMiMo/MiMo-V2.5-Pro", - "display_name": "XiaomiMiMo/MiMo-V2.5-Pro", + "id": "xiaomimimo/mimo-v2-flash", + "name": "XiaomiMiMo/MiMo-V2-Flash", + "display_name": "XiaomiMiMo/MiMo-V2-Flash", "modalities": { "input": [ "text" @@ -246004,7 +248359,7 @@ ] }, "limit": { - "context": 1048576, + "context": 262144, "output": 131072 }, "tool_call": true, @@ -246013,6 +248368,28 @@ "default": true }, "type": "chat" + }, + { + "id": "sao10k/l3-8b-lunaris", + "name": "Sao10k L3 8B Lunaris", + "display_name": "Sao10k L3 8B Lunaris", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" } ] }, @@ -246075,7 +248452,7 @@ "context": 1000000, "output": 128000 }, - "temperature": false, + "temperature": true, "tool_call": true, "reasoning": { "supported": true, @@ -246110,7 +248487,70 @@ "attachment": true, "open_weights": false, "knowledge": "2026-01-31", - "release_date": "2026-06-09", + "release_date": "2026-07-01", + "last_updated": "2026-06-09", + "cost": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "type": "chat" + }, + { + "id": "anthropic/claude-fable-5-free", + "name": "Anthropic: Claude Fable 5 (Free)", + "display_name": "Anthropic: Claude Fable 5 (Free)", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "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", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "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." + ] + } + }, + "attachment": true, + "open_weights": false, + "knowledge": "2026-01-31", + "release_date": "2026-07-01", "last_updated": "2026-06-09", "cost": { "input": 10, @@ -250793,7 +253233,7 @@ "cost": { "input": 1.25, "output": 3.75, - "cache_read": 0.125, + "cache_read": 0.25, "cache_write": 1.5625 }, "type": "chat" diff --git a/src/main/presenter/agentRuntimePresenter/dispatch.ts b/src/main/presenter/agentRuntimePresenter/dispatch.ts index 7692b7c81..de03bfc92 100644 --- a/src/main/presenter/agentRuntimePresenter/dispatch.ts +++ b/src/main/presenter/agentRuntimePresenter/dispatch.ts @@ -36,6 +36,7 @@ import { prepareToolImagePreviewPresentation } from './imageGenerationBlocks' import { + buildAssistantDeliverySegments, buildAssistantPreviewMarkdown, buildAssistantResponseMarkdown, emitDeepChatInternalSessionUpdate, @@ -1119,6 +1120,7 @@ function flushBlocksToRenderer(io: IoParams, blocks: AssistantMessageBlock[]): v messageId: io.messageId, previewMarkdown: buildAssistantPreviewMarkdown(blocks), responseMarkdown: buildAssistantResponseMarkdown(blocks), + deliverySegments: buildAssistantDeliverySegments(io.messageId, blocks), waitingInteraction: extractWaitingInteraction(blocks, io.messageId) }) } diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index cca971162..4610f1bf5 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -153,6 +153,7 @@ import type { ProviderCatalogPort, SessionPermissionPort, SessionUiPort } from ' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' import { extractToolCallImagePreviews } from '@/lib/toolCallImagePreviews' import { + buildAssistantDeliverySegments, buildAssistantPreviewMarkdown, buildAssistantResponseMarkdown, emitDeepChatInternalSessionUpdate, @@ -1229,6 +1230,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { emitRefreshBeforeStream?: boolean pendingQueueItemId?: string pendingQueueItemSource?: ProcessPendingInputSource + maxProviderRounds?: number } ): Promise { const state = this.runtimeState.get(sessionId) @@ -1447,6 +1449,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { promptPreview: normalizedInput.text, tools, baseSystemPrompt, + maxProviderRounds: context?.maxProviderRounds, refreshSystemPrompt: async (activeSkillNames, refreshedTools) => { const refreshedBasePrompt = await this.buildSystemPromptWithSkills( sessionId, @@ -3248,6 +3251,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { activeSkillNames: string[] | undefined, toolDefinitions: MCPToolDefinition[] ) => Promise + maxProviderRounds?: number preStreamStartedAt?: number onRunRegistered?: (runId: string) => void }): Promise<{ runId: string; result: ProcessResult }> { @@ -3263,6 +3267,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { interleavedReasoning: providedInterleavedReasoning, viewContext, refreshSystemPrompt, + maxProviderRounds, preStreamStartedAt, onRunRegistered } = args @@ -3407,6 +3412,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { onConversationMessagesChange: (nextMessages) => { reviewConversationMessages = nextMessages }, + maxProviderRounds, refreshTools: async (activeSkillNames) => await this.loadToolDefinitionsForSession( sessionId, @@ -7208,6 +7214,7 @@ export class AgentRuntimePresenter implements IAgentImplementation { messageId, previewMarkdown: buildAssistantPreviewMarkdown(blocks), responseMarkdown: buildAssistantResponseMarkdown(blocks), + deliverySegments: buildAssistantDeliverySegments(messageId, blocks), waitingInteraction: extractWaitingInteraction(blocks, messageId) }) } catch (error) { diff --git a/src/main/presenter/agentRuntimePresenter/internalSessionEvents.ts b/src/main/presenter/agentRuntimePresenter/internalSessionEvents.ts index 6e25ad526..f866ab9ec 100644 --- a/src/main/presenter/agentRuntimePresenter/internalSessionEvents.ts +++ b/src/main/presenter/agentRuntimePresenter/internalSessionEvents.ts @@ -1,5 +1,7 @@ import { EventEmitter } from 'events' import type { AssistantMessageBlock } from '@shared/types/agent-interface' +import type { RemoteDeliverySegment } from '../remoteControlPresenter/types' +import { buildRemoteDeliverySegments } from '../remoteControlPresenter/services/remoteBlockRenderer' export type DeepChatInternalSessionRuntimeStatus = 'idle' | 'generating' | 'error' @@ -18,6 +20,7 @@ export interface DeepChatInternalSessionUpdate { status?: DeepChatInternalSessionRuntimeStatus previewMarkdown?: string responseMarkdown?: string + deliverySegments?: RemoteDeliverySegment[] waitingInteraction?: DeepChatInternalSessionWaitingInteraction | null } @@ -65,6 +68,11 @@ export const buildAssistantPreviewMarkdown = (blocks: AssistantMessageBlock[]): return lines.slice(-3).join('\n') } +export const buildAssistantDeliverySegments = ( + messageId: string, + blocks: AssistantMessageBlock[] +): RemoteDeliverySegment[] => buildRemoteDeliverySegments(messageId, blocks) + export const extractWaitingInteraction = ( blocks: AssistantMessageBlock[], messageId: string diff --git a/src/main/presenter/agentRuntimePresenter/process.ts b/src/main/presenter/agentRuntimePresenter/process.ts index 628320518..81cd6167e 100644 --- a/src/main/presenter/agentRuntimePresenter/process.ts +++ b/src/main/presenter/agentRuntimePresenter/process.ts @@ -329,6 +329,11 @@ export async function processStream(params: ProcessParams): Promise 0 + ? params.maxProviderRounds! + : Number.POSITIVE_INFINITY let firstProviderRoundReady = false logger.info(`[ProcessStream] start session=${io.sessionId} message=${io.messageId}`) @@ -336,6 +341,20 @@ export async function processStream(params: ProcessParams): Promise maxProviderRounds) { + const errorMessage = `Maximum agent turns exceeded (${maxProviderRounds}).` + logger.info(`[ProcessStream] ${errorMessage}`) + finalizeError(state, io, errorMessage) + return { + status: 'error' as const, + terminalError: errorMessage, + stopReason: 'max_turns', + errorMessage, + usage: buildUsageSnapshot(state) + } + } + const prevBlockCount = state.blocks.length const stream = coreStream( diff --git a/src/main/presenter/agentRuntimePresenter/types.ts b/src/main/presenter/agentRuntimePresenter/types.ts index 21798e476..4a9dd6798 100644 --- a/src/main/presenter/agentRuntimePresenter/types.ts +++ b/src/main/presenter/agentRuntimePresenter/types.ts @@ -213,6 +213,7 @@ export interface ProcessParams { onFirstProviderRoundReady?: () => void onConversationMessagesChange?: (messages: ChatMessage[]) => void shouldYieldForPendingInput?: () => boolean + maxProviderRounds?: number hooks?: ProcessHooks io: IoParams } diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index 81c5c2a52..158e73295 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -515,7 +515,8 @@ export class AgentSessionPresenter { const sessionId = this.sessionManager.create(agentId, title, projectDir, { isDraft: false, disabledAgentTools, - subagentEnabled + subagentEnabled, + metadata: input.metadata ?? null }) try { @@ -555,6 +556,7 @@ export class AgentSessionPresenter { subagentMeta: null, createdAt: Date.now(), updatedAt: Date.now(), + ...(input.metadata ? { metadata: input.metadata } : {}), status: state?.status ?? 'idle', providerId: state?.providerId ?? providerId, modelId: state?.modelId ?? modelId @@ -729,7 +731,8 @@ export class AgentSessionPresenter { async sendMessage( sessionId: string, - content: string | SendMessageInput + content: string | SendMessageInput, + options?: { maxProviderRounds?: number } ): Promise { let session = this.sessionManager.get(sessionId) if (!session) throw new Error(`Session not found: ${sessionId}`) @@ -778,7 +781,8 @@ export class AgentSessionPresenter { } const result = await agent.processMessage(sessionId, normalizedInput, { - projectDir: session.projectDir ?? null + projectDir: session.projectDir ?? null, + maxProviderRounds: options?.maxProviderRounds }) if (!hadMessages && !wasDraft) { void this.generateSessionTitle(sessionId, session.title, providerId, state?.modelId ?? '') diff --git a/src/main/presenter/agentSessionPresenter/sessionManager.ts b/src/main/presenter/agentSessionPresenter/sessionManager.ts index 6357e63b7..f6c629463 100644 --- a/src/main/presenter/agentSessionPresenter/sessionManager.ts +++ b/src/main/presenter/agentSessionPresenter/sessionManager.ts @@ -3,6 +3,7 @@ import type { SQLitePresenter } from '../sqlitePresenter' import type { DeepChatSubagentMeta, SessionKind, + SessionMetadata, SessionPageCursor, SessionRecord } from '@shared/types/agent-interface' @@ -52,6 +53,7 @@ export class NewSessionManager { sessionKind?: SessionKind parentSessionId?: string | null subagentMeta?: DeepChatSubagentMeta | null + metadata?: SessionMetadata | null } ): string { const id = nanoid() @@ -63,6 +65,9 @@ export class NewSessionManager { parentSessionId: options?.parentSessionId, subagentMetaJson: options?.subagentMeta ? JSON.stringify(options.subagentMeta) : null }) + if (options?.metadata) { + this.sqlitePresenter.deepchatSessionMetadataTable.upsert(id, options.metadata) + } this.sqlitePresenter.deepchatSearchDocumentsTable.upsert({ documentKey: `session:${id}`, sessionId: id, @@ -187,6 +192,7 @@ export class NewSessionManager { delete(id: string): void { const affectedPaths = this.sqlitePresenter.newEnvironmentsTable.listPathsForSession(id) + this.sqlitePresenter.deepchatSessionMetadataTable?.delete(id) this.sqlitePresenter.deepchatSearchDocumentsTable.deleteBySession(id) this.sqlitePresenter.newSessionsTable.delete(id) for (const path of affectedPaths) { @@ -240,6 +246,7 @@ export class NewSessionManager { created_at: number updated_at: number }): SessionRecord { + const metadata = this.sqlitePresenter.deepchatSessionMetadataTable?.get(row.id) ?? null return { id: row.id, agentId: row.agent_id, @@ -252,7 +259,8 @@ export class NewSessionManager { subagentEnabled: row.subagent_enabled === 1, subagentMeta: parseSubagentMeta(row.subagent_meta_json), createdAt: row.created_at, - updatedAt: row.updated_at + updatedAt: row.updated_at, + ...(metadata ? { metadata } : {}) } } } diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index caa1d46d6..e348bc80d 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -27,6 +27,7 @@ import type { } from '@shared/presenter' import { ProviderBatchUpdate } from '@shared/provider-operations' import { SearchEngineTemplate } from '@shared/chat' +import { DEFAULT_DISABLED_AGENT_TOOLS } from '@shared/agentTools' import { ModelType, isNewApiEndpointType, @@ -105,11 +106,6 @@ import { createDefaultHooksNotificationsConfig, normalizeHooksNotificationsConfig } from '../hooksNotifications/config' -import { normalizeScheduledTasksConfig } from '../scheduledTasks/normalize' -import { - createDefaultScheduledTasksSettings, - type ScheduledTasksSettings -} from '@shared/scheduledTasks' import { AcpDbStore, AppSettingsDbBackedStore, @@ -152,7 +148,6 @@ interface IAppSettings { enableSkills?: boolean // Skills system global toggle skillDraftSuggestionsEnabled?: boolean // Whether agent may propose skill drafts after tasks hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings - scheduledTasks?: ScheduledTasksSettings // User-defined scheduled tasks defaultModel?: { providerId: string; modelId: string } // Default model for new conversations defaultVisionModel?: { providerId: string; modelId: string } // Legacy vision model setting for migration only defaultProjectPath?: string | null @@ -177,7 +172,7 @@ const defaultProviders = DEFAULT_PROVIDERS.map((provider) => ({ })) const PROVIDERS_STORE_KEY = 'providers' -const UNIFIED_AGENTS_MIGRATION_VERSION = 1 +const UNIFIED_AGENTS_MIGRATION_VERSION = 2 const DEPRECATED_BUILTIN_PROVIDER_IDS = ['qwenlm', 'laoshi'] as const type AnthropicLegacyProvider = LLM_PROVIDER & { authMode?: 'apikey' | 'oauth' } type ModelSelection = { providerId: string; modelId: string } @@ -216,6 +211,23 @@ const hasMemoryMaintenanceTriggerConfigUpdate = ( ) } +const withDeepChatAgentDefaults = (config: DeepChatAgentConfig): DeepChatAgentConfig => ({ + ...config, + disabledAgentTools: Array.isArray(config.disabledAgentTools) + ? [...config.disabledAgentTools] + : [...DEFAULT_DISABLED_AGENT_TOOLS] +}) + +const mergeDefaultDisabledAgentTools = ( + disabledAgentTools: DeepChatAgentConfig['disabledAgentTools'] +): string[] => + Array.from( + new Set([ + ...(Array.isArray(disabledAgentTools) ? disabledAgentTools : []), + ...DEFAULT_DISABLED_AGENT_TOOLS + ]) + ) + const hasLegacyAnthropicOAuthState = (provider: AnthropicLegacyProvider): boolean => Object.prototype.hasOwnProperty.call(provider, 'authMode') || provider.oauthToken !== undefined @@ -474,8 +486,7 @@ export class ConfigPresenter implements IConfigPresenter { skillDraftSuggestionsEnabled: false, // updateChannel 不预填,首次由 getUpdateChannel() 根据当前应用版本号推断(避免 beta 安装包被默认推入 stable 渠道) appVersion: this.currentAppVersion, - hooksNotifications: createDefaultHooksNotificationsConfig(), - scheduledTasks: createDefaultScheduledTasksSettings() + hooksNotifications: createDefaultHooksNotificationsConfig() } }) @@ -863,8 +874,9 @@ export class ConfigPresenter implements IConfigPresenter { config: this.buildLegacyBuiltinDeepChatConfig() }) - const migratedVersion = this.getSetting('unifiedAgentsMigrationVersion') ?? 0 - if (migratedVersion < UNIFIED_AGENTS_MIGRATION_VERSION) { + let migratedVersion = this.getSetting('unifiedAgentsMigrationVersion') ?? 0 + let registryAgentsSynced = false + if (migratedVersion < 1) { this.acpConfHelper.getManualAgents().forEach((agent) => { repository.createManualAcpAgent(agent) }) @@ -873,11 +885,34 @@ export class ConfigPresenter implements IConfigPresenter { this.acpConfHelper.getRegistryStates(), this.acpConfHelper.getInstallStates() ) + registryAgentsSynced = true + migratedVersion = 1 + } + + if (migratedVersion < 2) { + for (const agent of repository.listAgents({ agentType: 'deepchat' })) { + const config = repository.getDeepChatAgentConfig(agent.id) ?? {} + if (agent.id !== BUILTIN_DEEPCHAT_AGENT_ID && !Array.isArray(config.disabledAgentTools)) { + continue + } + + const disabledAgentTools = mergeDefaultDisabledAgentTools(config.disabledAgentTools) + if ( + !Array.isArray(config.disabledAgentTools) || + disabledAgentTools.length !== config.disabledAgentTools.length || + disabledAgentTools.some((tool) => !config.disabledAgentTools?.includes(tool)) + ) { + repository.updateDeepChatAgent(agent.id, { + config: { disabledAgentTools } + }) + } + } this.store.set('unifiedAgentsMigrationVersion', UNIFIED_AGENTS_MIGRATION_VERSION) - return } - this.syncRegistryAgentsToRepository() + if (!registryAgentsSynced) { + this.syncRegistryAgentsToRepository() + } } private reconcileLegacyBuiltinAgentSelections(): void { @@ -946,7 +981,7 @@ export class ConfigPresenter implements IConfigPresenter { : null, systemPrompt: (this.store.get('default_system_prompt') as string | undefined) ?? '', permissionMode: 'full_access', - disabledAgentTools: [], + disabledAgentTools: [...DEFAULT_DISABLED_AGENT_TOOLS], autoCompactionEnabled: typeof autoCompactionEnabled === 'boolean' ? autoCompactionEnabled : true, autoCompactionTriggerThreshold: @@ -976,7 +1011,9 @@ export class ConfigPresenter implements IConfigPresenter { } private getBuiltinDeepChatConfig(): DeepChatAgentConfig { - return this.agentRepository?.resolveDeepChatAgentConfig(BUILTIN_DEEPCHAT_AGENT_ID) ?? {} + return withDeepChatAgentDefaults( + this.agentRepository?.resolveDeepChatAgentConfig(BUILTIN_DEEPCHAT_AGENT_ID) ?? {} + ) } private updateBuiltinDeepChatConfig(updates: Partial): void { @@ -2686,12 +2723,15 @@ export class ConfigPresenter implements IConfigPresenter { } async getDeepChatAgentConfig(agentId: string): Promise { - return this.getAgentRepositoryOrThrow().getDeepChatAgentConfig(agentId) + const config = this.getAgentRepositoryOrThrow().getDeepChatAgentConfig(agentId) + return config ? withDeepChatAgentDefaults(config) : null } async resolveDeepChatAgentConfig(agentId: string): Promise { - return this.getAgentRepositoryOrThrow().resolveDeepChatAgentConfig( - agentId || BUILTIN_DEEPCHAT_AGENT_ID + return withDeepChatAgentDefaults( + this.getAgentRepositoryOrThrow().resolveDeepChatAgentConfig( + agentId || BUILTIN_DEEPCHAT_AGENT_ID + ) ) } @@ -3426,21 +3466,6 @@ export class ConfigPresenter implements IConfigPresenter { return normalized } - getScheduledTasksConfig(): ScheduledTasksSettings { - const raw = this.store.get('scheduledTasks') - const normalized = normalizeScheduledTasksConfig(raw) - if (!raw || JSON.stringify(raw) !== JSON.stringify(normalized)) { - this.store.set('scheduledTasks', normalized) - } - return normalized - } - - setScheduledTasksConfig(config: ScheduledTasksSettings): ScheduledTasksSettings { - const normalized = normalizeScheduledTasksConfig(config) - this.store.set('scheduledTasks', normalized) - return normalized - } - async testHookCommand(hookId: string): Promise { return await presenter.hooksNotifications.testHookCommand(hookId) } diff --git a/src/main/presenter/cronJobs/cronExpressionService.ts b/src/main/presenter/cronJobs/cronExpressionService.ts new file mode 100644 index 000000000..cc0dad2e5 --- /dev/null +++ b/src/main/presenter/cronJobs/cronExpressionService.ts @@ -0,0 +1,185 @@ +import { CronExpressionParser } from 'cron-parser' +import type { + CronJobMisfirePolicy, + CronSchedulePreset, + CronSchedulePreview, + CronScheduleValidation +} from '@shared/cronJobs' + +export interface CronJobSchedule { + cronExpr: string + timezone: string + misfirePolicy?: CronJobMisfirePolicy + maxCatchUpRuns?: number | null +} + +export interface DueRunReconciliation { + scheduledAts: number[] + nextRunAt: number | null + error: string | null +} + +const DEFAULT_PREVIEW_COUNT = 5 +const MAX_PREVIEW_COUNT = 10 +const DEFAULT_DUE_GRACE_MS = 60_000 + +const toErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const parseTime = (time: string): { hour: number; minute: number } => { + const match = /^(\d{1,2}):(\d{2})$/.exec(time) + if (!match) { + throw new Error(`Invalid time: ${time}`) + } + + const hour = Number(match[1]) + const minute = Number(match[2]) + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new Error(`Invalid time: ${time}`) + } + + return { hour, minute } +} + +export class CronExpressionService { + validate(cronExpr: string, timezone: string, from = Date.now()): CronScheduleValidation { + try { + return { + valid: true, + error: null, + nextRunAt: this.computeNextRunAt({ cronExpr, timezone }, from) + } + } catch (error) { + return { + valid: false, + error: toErrorMessage(error), + nextRunAt: null + } + } + } + + preview( + cronExpr: string, + timezone: string, + count = DEFAULT_PREVIEW_COUNT, + from = Date.now() + ): CronSchedulePreview { + try { + const safeCount = Math.min(Math.max(Math.trunc(count), 1), MAX_PREVIEW_COUNT) + const interval = CronExpressionParser.parse(cronExpr, { + currentDate: from, + tz: timezone + }) + return { + runs: interval.take(safeCount).map((date) => date.getTime()), + error: null + } + } catch (error) { + return { + runs: [], + error: toErrorMessage(error) + } + } + } + + computeNextRunAt(schedule: Pick, from = Date.now()) { + const interval = CronExpressionParser.parse(schedule.cronExpr, { + currentDate: from, + tz: schedule.timezone + }) + return interval.next().getTime() + } + + reconcileDueRun( + schedule: CronJobSchedule, + scheduledAt: number, + now = Date.now(), + dueGraceMs = DEFAULT_DUE_GRACE_MS + ): DueRunReconciliation { + try { + const scheduledAts = + schedule.misfirePolicy === 'run_once' + ? this.computeCatchUpRuns(schedule, scheduledAt, now) + : now - scheduledAt <= dueGraceMs + ? [scheduledAt] + : [] + + return { + scheduledAts, + nextRunAt: this.computeNextRunAt(schedule, now), + error: null + } + } catch (error) { + return { + scheduledAts: [], + nextRunAt: null, + error: toErrorMessage(error) + } + } + } + + presetToCron(preset: CronSchedulePreset): string { + switch (preset.type) { + case 'every_n_minutes': + if (!Number.isInteger(preset.n) || preset.n < 1 || preset.n > 59) { + throw new Error(`Invalid minute interval: ${preset.n}`) + } + return `*/${preset.n} * * * *` + case 'hourly': + if (!Number.isInteger(preset.minute) || preset.minute < 0 || preset.minute > 59) { + throw new Error(`Invalid minute: ${preset.minute}`) + } + return `${preset.minute} * * * *` + case 'daily': { + const { hour, minute } = parseTime(preset.time) + return `${minute} ${hour} * * *` + } + case 'weekdays': { + const { hour, minute } = parseTime(preset.time) + return `${minute} ${hour} * * 1-5` + } + case 'weekly': { + const { hour, minute } = parseTime(preset.time) + const days = [...new Set(preset.days)].sort((left, right) => left - right) + if (days.length === 0 || days.some((day) => !Number.isInteger(day) || day < 0 || day > 7)) { + throw new Error('Invalid weekly days') + } + return `${minute} ${hour} * * ${days.join(',')}` + } + case 'monthly': { + const { hour, minute } = parseTime(preset.time) + const day = preset.day === 'last' ? 'L' : preset.day + if (typeof day === 'number' && (!Number.isInteger(day) || day < 1 || day > 31)) { + throw new Error(`Invalid monthly day: ${day}`) + } + return `${minute} ${hour} ${day} * *` + } + case 'custom': + return preset.cronExpr + } + } + + private computeCatchUpRuns( + schedule: CronJobSchedule, + scheduledAt: number, + now: number + ): number[] { + const maxCatchUpRuns = schedule.maxCatchUpRuns ?? 1 + const safeLimit = Math.min(Math.max(Math.trunc(maxCatchUpRuns), 1), MAX_PREVIEW_COUNT) + const interval = CronExpressionParser.parse(schedule.cronExpr, { + currentDate: Math.max(0, scheduledAt - 1_000), + tz: schedule.timezone + }) + const scheduledAts: number[] = [] + + for (const date of interval.take(safeLimit)) { + const timestamp = date.getTime() + if (timestamp > now) { + break + } + scheduledAts.push(timestamp) + } + + return scheduledAts + } +} diff --git a/src/main/presenter/cronJobs/deliveryRouter.ts b/src/main/presenter/cronJobs/deliveryRouter.ts new file mode 100644 index 000000000..1dc44a9a9 --- /dev/null +++ b/src/main/presenter/cronJobs/deliveryRouter.ts @@ -0,0 +1,86 @@ +import type { + CronJob, + CronJobDeliveryReceipt, + CronJobDeliveryTarget, + CronJobRun +} from '@shared/cronJobs' +import { CronJobsRepository } from './repository' + +export interface CronJobRemoteDeliveryPort { + deliverCronJobResult(input: { + job: CronJob + run: CronJobRun + target: Extract + }): Promise<{ remoteMessageId?: string | null }> +} + +export interface CronJobDeliveryRouterDeps { + remoteDeliveryPort?: CronJobRemoteDeliveryPort +} + +export class CronJobDeliveryRouter { + constructor( + private readonly repository: CronJobsRepository, + private readonly deps: CronJobDeliveryRouterDeps = {} + ) {} + + setRemoteDeliveryPort(remoteDeliveryPort: CronJobRemoteDeliveryPort): void { + this.deps.remoteDeliveryPort = remoteDeliveryPort + } + + async deliver(input: { job: CronJob; run: CronJobRun }): Promise { + const targets = this.getTargets(input.job, input.run) + return await Promise.all(targets.map((target) => this.deliverTarget(input, target))) + } + + private getTargets(job: CronJob, run: CronJobRun): CronJobDeliveryTarget[] { + if (run.status === 'completed') { + return job.delivery.suppressSuccessNotification ? [] : job.delivery.targets + } + + if ((run.status === 'failed' || run.status === 'cancelled') && job.delivery.notifyOnFailure) { + return job.delivery.targets + } + + return [] + } + + private async deliverTarget( + input: { job: CronJob; run: CronJobRun }, + target: CronJobDeliveryTarget + ): Promise { + try { + const remoteMessageId = await this.dispatch(input, target) + return this.repository.recordDelivery({ + jobId: input.job.id, + runId: input.run.id, + target, + status: 'success', + remoteMessageId + }) + } catch (error) { + return this.repository.recordDelivery({ + jobId: input.job.id, + runId: input.run.id, + target, + status: 'failed', + error: error instanceof Error ? error.message : String(error) + }) + } + } + + private async dispatch( + input: { job: CronJob; run: CronJobRun }, + target: CronJobDeliveryTarget + ): Promise { + if (!this.deps.remoteDeliveryPort) { + throw new Error('Remote delivery is not available.') + } + + const result = await this.deps.remoteDeliveryPort.deliverCronJobResult({ + ...input, + target + }) + return result.remoteMessageId ?? null + } +} diff --git a/src/main/presenter/cronJobs/index.ts b/src/main/presenter/cronJobs/index.ts new file mode 100644 index 000000000..90a2b52ca --- /dev/null +++ b/src/main/presenter/cronJobs/index.ts @@ -0,0 +1,489 @@ +import type { PowerMonitor } from 'electron' +import { + CRON_JOBS_DEFAULT_DELIVERY as DEFAULT_DELIVERY, + CRON_JOBS_DEFAULT_RUNTIME as DEFAULT_RUNTIME, + type CronJob, + type CronJobAgentSnapshot, + type CronJobDeliveryReceipt, + type CronJobRun, + type CronJobsSchedulerStatus, + type CronJobStatus, + type CronSchedulePreview, + type CronScheduleValidation +} from '@shared/cronJobs' +import type { cronJobsUpsertInputSchema } from '@shared/contracts/routes/cronJobs.routes' +import type { IConfigPresenter } from '@shared/presenter' +import type { z } from 'zod' +import type { SQLitePresenter } from '../sqlitePresenter' +import { CronExpressionService } from './cronExpressionService' +import { CronJobDeliveryRouter, type CronJobRemoteDeliveryPort } from './deliveryRouter' +import { CronJobsRepository } from './repository' +import { CronJobRunExecutor, type CronJobRunSessionStarter } from './runExecutor' +import { CronJobRuntimeResolver } from './runtimeResolver' +import { + SchedulerProcessManager, + type SchedulerProcessManagerDeps, + type SchedulerRunDueEvent +} from './schedulerProcessManager' + +export type CronJobsUpsertInput = z.input +type CronJobDraft = Omit & { + id?: string +} + +export interface CronJobsServiceDeps { + sqlitePresenter: SQLitePresenter + configPresenter?: Pick + schedulerManager?: SchedulerProcessManager + scheduleService?: CronExpressionService + runtimeResolver?: CronJobRuntimeResolver + deliveryRouter?: CronJobDeliveryRouter + runSessionStarter?: CronJobRunSessionStarter + createSchedulerManager?: ( + deps: Omit + ) => SchedulerProcessManager + powerMonitor?: Pick +} + +export class CronJobsService { + private readonly repository: CronJobsRepository + private readonly schedulerManager: SchedulerProcessManager + private readonly scheduleService: CronExpressionService + private readonly deliveryRouter: CronJobDeliveryRouter + private readonly runtimeResolver: CronJobRuntimeResolver | null + private runExecutor: CronJobRunExecutor | null = null + private started = false + private powerMonitor: Pick | null = null + private readonly resumeHandler = () => { + void this.reconcileScheduler('system-resume') + } + + constructor(deps: CronJobsServiceDeps) { + this.repository = new CronJobsRepository(deps.sqlitePresenter) + this.scheduleService = deps.scheduleService ?? new CronExpressionService() + this.runtimeResolver = + deps.runtimeResolver ?? + (deps.configPresenter ? new CronJobRuntimeResolver(deps.configPresenter) : null) + this.deliveryRouter = deps.deliveryRouter ?? new CronJobDeliveryRouter(this.repository) + if (deps.runSessionStarter) { + this.runExecutor = new CronJobRunExecutor( + this.repository, + deps.runSessionStarter, + this.deliveryRouter + ) + } + const managerDeps: Omit = { + dbPath: deps.sqlitePresenter.getDatabasePath(), + dbPassword: deps.sqlitePresenter.getDatabasePassword(), + getSnapshot: () => this.repository.getSchedulerSnapshot(), + onRunDue: async (event) => { + await this.processDueRun(event) + } + } + this.schedulerManager = + deps.schedulerManager ?? + deps.createSchedulerManager?.(managerDeps) ?? + new SchedulerProcessManager(managerDeps) + this.powerMonitor = deps.powerMonitor ?? null + } + + start(): void { + if (this.started) { + return + } + this.started = true + this.failStaleRunningRuns() + void this.attachPowerMonitor() + void this.reconcileScheduler('startup') + } + + async stop(): Promise { + this.started = false + if (this.powerMonitor) { + this.powerMonitor.off('resume', this.resumeHandler) + this.powerMonitor = null + } + await this.schedulerManager.stop('app-quit') + } + + async list(): Promise<{ + jobs: CronJob[] + schedulerStatus: CronJobsSchedulerStatus + }> { + await this.reconcileScheduler('list') + return { + jobs: this.repository.listJobs(), + schedulerStatus: this.schedulerManager.getStatus() + } + } + + async upsert(input: CronJobsUpsertInput): Promise<{ + job: CronJob + schedulerStatus: CronJobsSchedulerStatus + }> { + const draft = this.buildJobDraft(input) + const jobState = await this.computeJobState(draft, Date.now(), true) + const job = this.repository.upsertJob({ + ...draft, + enabled: jobState.enabled, + status: jobState.status, + nextRunAt: jobState.nextRunAt, + scheduleError: jobState.scheduleError, + agentSnapshot: jobState.agentSnapshot + }) + const schedulerStatus = await this.reconcileScheduler('job-upsert') + return { job, schedulerStatus } + } + + async delete(id: string): Promise { + this.repository.deleteJob(id) + return await this.reconcileScheduler('job-delete') + } + + async toggle( + id: string, + enabled: boolean + ): Promise<{ + job: CronJob + schedulerStatus: CronJobsSchedulerStatus + }> { + const existing = this.repository.requireJob(id) + const draft = this.buildJobDraft({ + ...existing, + enabled + }) + const jobState = await this.computeJobState(draft, Date.now(), true) + const job = this.repository.upsertJob({ + ...draft, + enabled: jobState.enabled, + status: jobState.status, + nextRunAt: jobState.nextRunAt, + scheduleError: jobState.scheduleError, + agentSnapshot: jobState.agentSnapshot + }) + const schedulerStatus = await this.reconcileScheduler('job-toggle') + return { job, schedulerStatus } + } + + async runNow(id: string): Promise<{ + job: CronJob + run: CronJobRun + schedulerStatus: CronJobsSchedulerStatus + }> { + const job = this.repository.requireJob(id) + await this.assertRunnable(job) + const run = this.repository.queueRun({ + jobId: id, + scheduledAt: Date.now(), + reason: 'manual' + }) + await this.processDueRun({ + jobId: id, + runId: run.id, + scheduledAt: run.scheduledAt, + reason: run.reason + }) + const completed = this.repository.getRun(run.id) ?? run + const schedulerStatus = await this.reconcileScheduler('manual-run') + return { job, run: completed, schedulerStatus } + } + + listRuns(jobId: string, limit?: number): CronJobRun[] { + this.repository.requireJob(jobId) + return this.repository.listRunsByJob(jobId, limit) + } + + getRun(id: string): CronJobRun { + return this.repository.requireRun(id) + } + + listDeliveries(runId: string): CronJobDeliveryReceipt[] { + this.repository.requireRun(runId) + return this.repository.listDeliveriesByRun(runId) + } + + setRunSessionStarter(runSessionStarter: CronJobRunSessionStarter): void { + this.runExecutor?.dispose() + this.runExecutor = new CronJobRunExecutor( + this.repository, + runSessionStarter, + this.deliveryRouter + ) + } + + setRemoteDeliveryPort(remoteDeliveryPort: CronJobRemoteDeliveryPort): void { + this.deliveryRouter.setRemoteDeliveryPort(remoteDeliveryPort) + } + + getSchedulerStatus(): CronJobsSchedulerStatus { + return this.schedulerManager.getStatus() + } + + async reconcileScheduler(reason = 'manual'): Promise { + await this.reconcileStoredSchedules(Date.now()) + return await this.schedulerManager.reconcile(reason) + } + + async restartScheduler(): Promise { + return await this.schedulerManager.restart() + } + + validateSchedule(input: { + cronExpr: string + timezone: string + from?: number + }): CronScheduleValidation { + return this.scheduleService.validate(input.cronExpr, input.timezone, input.from) + } + + previewSchedule(input: { + cronExpr: string + timezone: string + count?: number + from?: number + }): CronSchedulePreview { + return this.scheduleService.preview(input.cronExpr, input.timezone, input.count, input.from) + } + + private async attachPowerMonitor(): Promise { + if (this.powerMonitor) { + this.powerMonitor.off('resume', this.resumeHandler) + this.powerMonitor.on('resume', this.resumeHandler) + return + } + + try { + const { powerMonitor } = await import('electron') + this.powerMonitor = powerMonitor + powerMonitor.off('resume', this.resumeHandler) + powerMonitor.on('resume', this.resumeHandler) + } catch (error) { + console.warn('[CronJobs] Failed to attach power monitor resume handler:', error) + } + } + + private failStaleRunningRuns(): void { + const failedCount = this.repository.markRunningRunsFailed( + 'Cron job runner stopped before completion.' + ) + if (failedCount > 0) { + console.warn('[CronJobs] Marked stale running runs as failed:', { failedCount }) + } + } + + private buildJobDraft(input: CronJobsUpsertInput): CronJobDraft { + const existing = input.id ? this.repository.getJob(input.id) : null + return { + id: input.id, + name: input.name, + description: input.description ?? existing?.description ?? null, + enabled: input.enabled, + status: input.status ?? existing?.status ?? (input.enabled ? 'ready' : 'disabled'), + cronExpr: input.cronExpr, + timezone: input.timezone, + agentId: input.agentId, + nextRunAt: input.nextRunAt ?? existing?.nextRunAt ?? null, + misfirePolicy: input.misfirePolicy ?? existing?.misfirePolicy ?? 'skip', + maxCatchUpRuns: input.maxCatchUpRuns ?? existing?.maxCatchUpRuns ?? null, + scheduleError: input.scheduleError ?? existing?.scheduleError ?? null, + taskPrompt: input.taskPrompt ?? existing?.taskPrompt ?? '', + taskSystemInstruction: input.taskSystemInstruction ?? existing?.taskSystemInstruction ?? null, + taskOutputMode: input.taskOutputMode ?? existing?.taskOutputMode ?? 'final_message', + modelPolicy: input.modelPolicy ?? existing?.modelPolicy ?? 'follow_agent', + toolPolicy: input.toolPolicy ?? existing?.toolPolicy ?? 'follow_agent', + permissionPolicy: input.permissionPolicy ?? existing?.permissionPolicy ?? 'follow_agent', + runtime: input.runtime ?? existing?.runtime ?? { ...DEFAULT_RUNTIME }, + agentSnapshot: input.agentSnapshot ?? existing?.agentSnapshot ?? null, + delivery: + input.delivery ?? + existing?.delivery ?? + ({ + ...DEFAULT_DELIVERY, + targets: [...DEFAULT_DELIVERY.targets] + } as CronJobDraft['delivery']) + } + } + + private async computeJobState( + input: Pick< + CronJob, + | 'enabled' + | 'cronExpr' + | 'timezone' + | 'agentId' + | 'taskPrompt' + | 'modelPolicy' + | 'toolPolicy' + | 'permissionPolicy' + | 'agentSnapshot' + >, + now: number, + throwOnInvalid: boolean + ): Promise<{ + enabled: boolean + status: CronJobStatus + nextRunAt: number | null + scheduleError: string | null + agentSnapshot: CronJobAgentSnapshot | null + }> { + const validation = this.scheduleService.validate(input.cronExpr, input.timezone, now) + let status = await this.resolveAgentStatus(input) + let enabled = input.enabled + const taskPrompt = input.taskPrompt.trim() + + if (!enabled && status === 'ready') { + status = 'disabled' + } + + if (enabled && !taskPrompt) { + if (throwOnInvalid) { + throw new Error('Cron job task prompt is required.') + } + enabled = false + status = 'disabled' + } + + if (enabled && status !== 'ready') { + if (throwOnInvalid) { + throw new Error('Cron job requires an enabled agent.') + } + enabled = false + } + + if (!validation.valid && enabled) { + if (throwOnInvalid) { + throw new Error(validation.error ?? 'Invalid cron schedule.') + } + enabled = false + } + + return { + enabled, + status, + nextRunAt: enabled && status === 'ready' ? validation.nextRunAt : null, + scheduleError: validation.error, + agentSnapshot: await this.captureSnapshotIfNeeded(input) + } + } + + private async resolveAgentStatus( + input: Pick< + CronJob, + 'agentId' | 'modelPolicy' | 'toolPolicy' | 'permissionPolicy' | 'agentSnapshot' + > + ): Promise { + if (!input.agentId) { + return 'disabled' + } + if (!this.runtimeResolver) { + return 'ready' + } + return (await this.runtimeResolver.resolve(input)).status + } + + private async captureSnapshotIfNeeded( + input: Pick< + CronJob, + 'agentId' | 'modelPolicy' | 'toolPolicy' | 'permissionPolicy' | 'agentSnapshot' + > + ): Promise { + const shouldCapture = + input.modelPolicy === 'pin_current' || + input.toolPolicy === 'snapshot' || + input.permissionPolicy === 'snapshot' + if (!shouldCapture) { + return null + } + return (await this.runtimeResolver?.captureSnapshot(input.agentId)) ?? input.agentSnapshot + } + + private async assertRunnable(job: CronJob): Promise { + const state = await this.computeJobState(job, Date.now(), true) + if (!state.enabled || state.status !== 'ready') { + throw new Error('Cron job is not runnable.') + } + } + + private async reconcileStoredSchedules(now: number): Promise { + for (const job of this.repository.listJobs()) { + const state = await this.computeJobState(job, now, false) + if ( + job.enabled === state.enabled && + job.status === state.status && + job.nextRunAt === state.nextRunAt && + job.scheduleError === state.scheduleError && + JSON.stringify(job.agentSnapshot) === JSON.stringify(state.agentSnapshot) + ) { + continue + } + this.repository.upsertJob({ + ...job, + enabled: state.enabled, + status: state.status, + nextRunAt: state.nextRunAt, + scheduleError: state.scheduleError, + agentSnapshot: state.agentSnapshot, + now + }) + } + } + + private async processDueRun(event: SchedulerRunDueEvent): Promise { + console.info('[CronJobs] Processing due run:', { + jobId: event.jobId, + runId: event.runId, + scheduledAt: event.scheduledAt, + reason: event.reason + }) + const run = this.repository.getRun(event.runId) + const job = this.repository.getJob(event.jobId) + if (!run) { + console.warn('[CronJobs] Ignoring unknown run from scheduler:', event.runId) + return + } + if (!job) { + this.repository.markRunFailed(event.runId, `Unknown cron job: ${event.jobId}`) + return + } + + try { + await this.assertRunnable(job) + if (this.runExecutor) { + console.info('[CronJobs] Dispatching due run to executor:', { + jobId: job.id, + runId: event.runId, + jobName: job.name + }) + await this.runExecutor.execute({ + runId: event.runId, + job + }) + } else { + await this.failRunAndDeliver( + event.runId, + job, + 'Cron job session starter is not initialized.' + ) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + try { + await this.failRunAndDeliver(event.runId, job, message) + } catch (markError) { + console.error('[CronJobs] Failed to mark run as failed:', markError) + } + } + } + + private async failRunAndDeliver(runId: string, job: CronJob, message: string): Promise { + const failed = this.repository.markRunFailed(runId, message) + try { + await this.deliveryRouter.deliver({ job, run: failed }) + } catch (error) { + console.error('[CronJobs] Failed to deliver failed run:', error) + } + } +} + +export { CronJobsRepository } +export type { CronJobRunSessionStarter } diff --git a/src/main/presenter/cronJobs/repository.ts b/src/main/presenter/cronJobs/repository.ts new file mode 100644 index 000000000..3fa0ede2e --- /dev/null +++ b/src/main/presenter/cronJobs/repository.ts @@ -0,0 +1,328 @@ +import { + CRON_JOBS_DEFAULT_DELIVERY, + CRON_JOBS_DEFAULT_MISFIRE_POLICY, + CRON_JOBS_DEFAULT_RUNTIME, + type CronJobAgentSnapshot, + type CronJobDelivery, + type CronJobDeliveryReceipt, + type CronJobRuntimeSettings, + type CronJob, + type CronJobRun, + type CronJobRunReason +} from '@shared/cronJobs' +import type { cronJobsUpsertInputSchema } from '@shared/contracts/routes/cronJobs.routes' +import type { z } from 'zod' +import type { SQLitePresenter } from '../sqlitePresenter' +import type { CronJobDeliveryRow } from '../sqlitePresenter/tables/cronJobDeliveries' +import type { CronJobRow } from '../sqlitePresenter/tables/cronJobs' +import type { CronJobRunRow } from '../sqlitePresenter/tables/cronJobRuns' + +export type CronJobUpsertInput = z.input & { + now?: number +} + +export interface CronJobsSchedulerSnapshot { + enabledJobCount: number + nextRunAt: number | null +} + +export class CronJobsRepository { + constructor(private readonly sqlitePresenter: SQLitePresenter) {} + + listJobs(): CronJob[] { + return this.sqlitePresenter.cronJobsTable.list().map(toCronJob) + } + + getJob(id: string): CronJob | null { + const row = this.sqlitePresenter.cronJobsTable.get(id) + return row ? toCronJob(row) : null + } + + requireJob(id: string): CronJob { + const job = this.getJob(id) + if (!job) { + throw new Error(`Unknown cron job: ${id}`) + } + return job + } + + upsertJob(input: CronJobUpsertInput): CronJob { + const row = this.sqlitePresenter.cronJobsTable.upsert({ + id: input.id, + name: input.name, + description: input.description, + enabled: input.enabled, + status: input.status, + cronExpr: input.cronExpr, + timezone: input.timezone, + agentId: input.agentId, + nextRunAt: input.nextRunAt, + misfirePolicy: input.misfirePolicy, + maxCatchUpRuns: input.maxCatchUpRuns, + scheduleError: input.scheduleError, + taskPrompt: input.taskPrompt, + taskSystemInstruction: input.taskSystemInstruction, + taskOutputMode: input.taskOutputMode, + modelPolicy: input.modelPolicy, + toolPolicy: input.toolPolicy, + permissionPolicy: input.permissionPolicy, + runtime: input.runtime, + agentSnapshot: input.agentSnapshot, + delivery: input.delivery, + now: input.now + }) + return toCronJob(row) + } + + deleteJob(id: string): void { + this.sqlitePresenter.getDatabase().transaction(() => { + this.sqlitePresenter.cronJobDeliveriesTable.deleteByJob(id) + this.sqlitePresenter.cronJobRunsTable.deleteByJob(id) + this.sqlitePresenter.cronJobsTable.delete(id) + })() + } + + setJobEnabled(id: string, enabled: boolean): CronJob { + return toCronJob(this.sqlitePresenter.cronJobsTable.setEnabled(id, enabled)) + } + + updateJobNextRunAt(id: string, nextRunAt: number | null): CronJob { + return toCronJob(this.sqlitePresenter.cronJobsTable.updateNextRunAt(id, nextRunAt)) + } + + updateScheduleState( + id: string, + input: { + nextRunAt: number | null + scheduleError: string | null + now?: number + } + ): CronJob { + return toCronJob(this.sqlitePresenter.cronJobsTable.updateScheduleState(id, input)) + } + + getSchedulerSnapshot(): CronJobsSchedulerSnapshot { + return { + enabledJobCount: this.sqlitePresenter.cronJobsTable.countEnabled(), + nextRunAt: this.sqlitePresenter.cronJobsTable.getNextEnabledRunAt() + } + } + + queueRun(input: { + jobId: string + scheduledAt: number + reason: CronJobRunReason + now?: number + }): CronJobRun { + return toCronJobRun( + this.sqlitePresenter.cronJobRunsTable.insertQueued({ + jobId: input.jobId, + scheduledAt: input.scheduledAt, + reason: input.reason, + now: input.now + }) + ) + } + + getRun(id: string): CronJobRun | null { + const row = this.sqlitePresenter.cronJobRunsTable.get(id) + return row ? toCronJobRun(row) : null + } + + requireRun(id: string): CronJobRun { + const run = this.getRun(id) + if (!run) { + throw new Error(`Unknown cron job run: ${id}`) + } + return run + } + + markRunRunning(id: string): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.markRunning(id)) + } + + claimRun(id: string, claimOwner: string): CronJobRun | null { + const row = this.sqlitePresenter.cronJobRunsTable.claimQueued(id, claimOwner) + return row ? toCronJobRun(row) : null + } + + updateRunSession(id: string, sessionId: string): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.updateSession(id, sessionId)) + } + + updateRunOutput( + id: string, + input: { + outputMessageId?: string | null + outputPreview?: string | null + } + ): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.updateOutput(id, input)) + } + + markRunCompleted(id: string): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.markCompleted(id)) + } + + markRunFailed(id: string, error: string): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.markFailed(id, error)) + } + + markRunningRunsFailed(error: string): number { + return this.sqlitePresenter.cronJobRunsTable.markRunningFailed(error) + } + + markRunCancelled(id: string, error?: string | null): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.markCancelled(id, error ?? null)) + } + + releaseRunQueued(id: string): CronJobRun { + return toCronJobRun(this.sqlitePresenter.cronJobRunsTable.releaseQueued(id)) + } + + countActiveRunsByJob(jobId: string, excludeRunId?: string): number { + return this.sqlitePresenter.cronJobRunsTable.countActiveByJob(jobId, excludeRunId) + } + + listRunsByJob(jobId: string, limit?: number): CronJobRun[] { + return this.sqlitePresenter.cronJobRunsTable.listByJob(jobId, limit).map(toCronJobRun) + } + + recordDelivery(input: { + jobId: string + runId: string + target: CronJobDeliveryReceipt['target'] + status: CronJobDeliveryReceipt['status'] + remoteMessageId?: string | null + error?: string | null + now?: number + }): CronJobDeliveryReceipt { + return toCronJobDeliveryReceipt(this.sqlitePresenter.cronJobDeliveriesTable.insert(input)) + } + + listDeliveriesByRun(runId: string): CronJobDeliveryReceipt[] { + return this.sqlitePresenter.cronJobDeliveriesTable + .listByRun(runId) + .map(toCronJobDeliveryReceipt) + } + + findDeliveryByRemoteMessageId(remoteMessageId: string): CronJobDeliveryReceipt | null { + const row = this.sqlitePresenter.cronJobDeliveriesTable.findByRemoteMessageId(remoteMessageId) + return row ? toCronJobDeliveryReceipt(row) : null + } +} + +export function toCronJob(row: CronJobRow): CronJob { + return { + id: row.id, + name: row.name, + description: row.description ?? null, + enabled: row.enabled === 1, + status: + row.status ?? (row.agent_id ? (row.enabled === 1 ? 'ready' : 'disabled') : 'invalid_agent'), + cronExpr: row.cron_expr, + timezone: row.timezone, + agentId: row.agent_id, + nextRunAt: row.next_run_at, + misfirePolicy: row.misfire_policy ?? CRON_JOBS_DEFAULT_MISFIRE_POLICY, + maxCatchUpRuns: row.max_catch_up_runs ?? null, + scheduleError: row.schedule_error ?? null, + taskPrompt: row.task_prompt ?? '', + taskSystemInstruction: row.task_system_instruction ?? null, + taskOutputMode: row.task_output_mode ?? 'final_message', + modelPolicy: row.model_policy ?? 'follow_agent', + toolPolicy: row.tool_policy ?? 'follow_agent', + permissionPolicy: row.permission_policy ?? 'follow_agent', + runtime: parseRuntime(row.runtime_json), + agentSnapshot: parseSnapshot(row.agent_snapshot_json), + delivery: parseDelivery(row.delivery_json), + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +function parseRuntime(value: string | null | undefined): CronJobRuntimeSettings { + try { + const parsed = value ? (JSON.parse(value) as Partial) : {} + return { + maxDurationMs: parsed.maxDurationMs ?? CRON_JOBS_DEFAULT_RUNTIME.maxDurationMs, + maxTurns: parsed.maxTurns ?? CRON_JOBS_DEFAULT_RUNTIME.maxTurns, + concurrencyPolicy: parsed.concurrencyPolicy ?? CRON_JOBS_DEFAULT_RUNTIME.concurrencyPolicy + } + } catch { + return { ...CRON_JOBS_DEFAULT_RUNTIME } + } +} + +function parseSnapshot(value: string | null | undefined): CronJobAgentSnapshot | null { + if (!value) { + return null + } + try { + return JSON.parse(value) as CronJobAgentSnapshot + } catch { + return null + } +} + +function parseDelivery(value: string | null | undefined): CronJobDelivery { + try { + const parsed = value ? (JSON.parse(value) as Partial) : {} + return { + targets: Array.isArray(parsed.targets) + ? parsed.targets.filter( + (target): target is CronJobDelivery['targets'][number] => + Boolean(target) && + typeof target === 'object' && + (target as { type?: unknown }).type === 'remote' && + typeof (target as { remoteId?: unknown }).remoteId === 'string' && + typeof (target as { channelId?: unknown }).channelId === 'string' && + ((target as { mode?: unknown }).mode === 'summary' || + (target as { mode?: unknown }).mode === 'full') + ) + : [...CRON_JOBS_DEFAULT_DELIVERY.targets], + suppressSuccessNotification: + parsed.suppressSuccessNotification ?? + CRON_JOBS_DEFAULT_DELIVERY.suppressSuccessNotification, + notifyOnFailure: parsed.notifyOnFailure ?? CRON_JOBS_DEFAULT_DELIVERY.notifyOnFailure + } as CronJobDelivery + } catch { + return { ...CRON_JOBS_DEFAULT_DELIVERY, targets: [...CRON_JOBS_DEFAULT_DELIVERY.targets] } + } +} + +export function toCronJobRun(row: CronJobRunRow): CronJobRun { + return { + id: row.id, + jobId: row.job_id, + sessionId: row.session_id ?? null, + scheduledAt: row.scheduled_at, + queuedAt: row.queued_at, + startedAt: row.started_at, + completedAt: row.completed_at, + status: row.status, + reason: row.reason, + outputMessageId: row.output_message_id ?? null, + outputPreview: row.output_preview ?? null, + error: row.error, + claimedAt: row.claimed_at ?? null, + claimOwner: row.claim_owner ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +export function toCronJobDeliveryReceipt(row: CronJobDeliveryRow): CronJobDeliveryReceipt { + return { + id: row.id, + jobId: row.job_id, + runId: row.run_id, + targetType: row.target_type, + target: JSON.parse(row.target_json) as CronJobDeliveryReceipt['target'], + status: row.status, + remoteMessageId: row.remote_message_id ?? null, + error: row.error, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} diff --git a/src/main/presenter/cronJobs/runExecutor.ts b/src/main/presenter/cronJobs/runExecutor.ts new file mode 100644 index 000000000..8fd3d5205 --- /dev/null +++ b/src/main/presenter/cronJobs/runExecutor.ts @@ -0,0 +1,321 @@ +import { randomUUID } from 'node:crypto' +import type { CronJob, CronJobRun } from '@shared/cronJobs' +import { + subscribeDeepChatInternalSessionUpdates, + type DeepChatInternalSessionUpdate +} from '../agentRuntimePresenter/internalSessionEvents' +import type { CronJobDeliveryRouter } from './deliveryRouter' +import { CronJobsRepository } from './repository' + +type CronRunDeliverySegment = NonNullable[number] + +const formatRunDeliverySegment = (segment: CronRunDeliverySegment): string => { + const text = segment.text.trim() + if (!text) { + return '' + } + + if (segment.kind === 'process') { + return `Process\n${text}` + } + + if (segment.kind === 'terminal') { + return `Status\n${text}` + } + + return `Answer\n${text}` +} + +const getRunOutputPreview = (update: DeepChatInternalSessionUpdate): string | null => { + const deliveryText = + update.deliverySegments?.map(formatRunDeliverySegment).filter(Boolean).join('\n\n').trim() ?? '' + return deliveryText || update.responseMarkdown || update.previewMarkdown || null +} + +export interface CronJobRunSessionStarter { + createSessionForRun(input: { job: CronJob; run: CronJobRun }): Promise<{ + sessionId: string + outputMessageId?: string | null + outputPreview?: string | null + }> + startSessionRun(input: { job: CronJob; run: CronJobRun; sessionId: string }): Promise<{ + outputMessageId?: string | null + outputPreview?: string | null + }> + cancelSessionRun?(input: { + job: CronJob + run: CronJobRun + sessionId: string + reason: string + }): Promise +} + +export class CronJobRunExecutor { + private readonly claimOwner = `cron-job-runner:${process.pid}:${randomUUID()}` + private readonly activeSessions = new Map< + string, + { runId: string; job: CronJob; timeout: ReturnType | null } + >() + private readonly unsubscribeSessionUpdates: () => void + + constructor( + private readonly repository: CronJobsRepository, + private readonly sessionStarter: CronJobRunSessionStarter, + private readonly deliveryRouter?: CronJobDeliveryRouter + ) { + this.unsubscribeSessionUpdates = subscribeDeepChatInternalSessionUpdates((update) => + this.handleSessionUpdate(update) + ) + } + + dispose(): void { + this.unsubscribeSessionUpdates() + for (const sessionId of this.activeSessions.keys()) { + this.clearActiveSession(sessionId) + } + this.activeSessions.clear() + } + + async execute(input: { runId: string; job: CronJob }): Promise { + console.info('[CronJobs] Executor claiming run:', { + jobId: input.job.id, + runId: input.runId, + jobName: input.job.name + }) + const run = this.repository.claimRun(input.runId, this.claimOwner) + if (!run) { + console.warn('[CronJobs] Executor could not claim run:', { + jobId: input.job.id, + runId: input.runId + }) + return this.repository.getRun(input.runId) + } + + const activeRuns = this.repository.countActiveRunsByJob(input.job.id, run.id) + if (activeRuns > 0) { + if (input.job.runtime.concurrencyPolicy === 'queue') { + console.info('[CronJobs] Requeued run because another run is active:', { + jobId: input.job.id, + runId: run.id, + activeRuns + }) + return this.repository.releaseRunQueued(run.id) + } + console.info('[CronJobs] Skipped run because another run is active:', { + jobId: input.job.id, + runId: run.id, + activeRuns + }) + const cancelled = this.repository.markRunCancelled( + run.id, + 'Another cron job run is already active.' + ) + return cancelled + } + + let sessionId: string | null = null + try { + console.info('[CronJobs] Creating session for run:', { + jobId: input.job.id, + runId: run.id, + agentId: input.job.agentId + }) + const result = await this.sessionStarter.createSessionForRun({ job: input.job, run }) + sessionId = result.sessionId + console.info('[CronJobs] Created session for run:', { + jobId: input.job.id, + runId: run.id, + sessionId + }) + this.repository.updateRunSession(run.id, result.sessionId) + this.activeSessions.set(result.sessionId, { + runId: run.id, + job: input.job, + timeout: this.createRunTimeout(run.id, input.job, result.sessionId) + }) + if (result.outputMessageId || result.outputPreview) { + this.repository.updateRunOutput(run.id, { + outputMessageId: result.outputMessageId ?? null, + outputPreview: result.outputPreview ?? null + }) + } + console.info('[CronJobs] Starting session run:', { + jobId: input.job.id, + runId: run.id, + sessionId: result.sessionId + }) + const startResult = await this.sessionStarter.startSessionRun({ + job: input.job, + run, + sessionId: result.sessionId + }) + console.info('[CronJobs] Session run started:', { + jobId: input.job.id, + runId: run.id, + sessionId: result.sessionId, + outputMessageId: startResult?.outputMessageId ?? null + }) + if (startResult?.outputMessageId || startResult?.outputPreview) { + this.repository.updateRunOutput(run.id, { + outputMessageId: startResult.outputMessageId ?? null, + outputPreview: startResult.outputPreview ?? null + }) + } + return this.repository.getRun(run.id) + } catch (error) { + if (sessionId) { + this.clearActiveSession(sessionId) + } + console.error('[CronJobs] Run execution failed:', { + jobId: input.job.id, + runId: run.id, + sessionId, + error + }) + const current = this.repository.getRun(run.id) + if (!current || current.status !== 'running') { + return current + } + const failed = this.repository.markRunFailed( + run.id, + error instanceof Error ? error.message : String(error) + ) + await this.deliverRun(input.job, failed) + return failed + } + } + + private handleSessionUpdate(update: DeepChatInternalSessionUpdate): void { + const activeSession = this.activeSessions.get(update.sessionId) + if (!activeSession) { + return + } + + if (update.kind === 'blocks') { + this.captureRunOutput(activeSession.runId, update) + return + } + + if (update.kind !== 'status') { + return + } + + if (update.status === 'idle') { + this.completeRun(activeSession.runId, activeSession.job, update.sessionId) + return + } + + if (update.status === 'error') { + this.failRun(activeSession.runId, activeSession.job, update.sessionId) + } + } + + private captureRunOutput(runId: string, update: DeepChatInternalSessionUpdate): void { + const current = this.repository.getRun(runId) + if (!current || current.status !== 'running') { + return + } + const outputPreview = getRunOutputPreview(update) + if (!update.messageId && !outputPreview) { + return + } + this.repository.updateRunOutput(runId, { + outputMessageId: update.messageId ?? null, + outputPreview + }) + } + + private completeRun(runId: string, job: CronJob, sessionId: string): void { + const current = this.repository.getRun(runId) + if (!current || current.status !== 'running') { + this.clearActiveSession(sessionId) + return + } + const completed = this.repository.markRunCompleted(runId) + this.clearActiveSession(sessionId) + void this.deliverRun(job, completed) + } + + private failRun(runId: string, job: CronJob, sessionId: string): void { + const current = this.repository.getRun(runId) + if (!current || current.status !== 'running') { + this.clearActiveSession(sessionId) + return + } + const failed = this.repository.markRunFailed(runId, 'Agent session entered error state.') + this.clearActiveSession(sessionId) + void this.deliverRun(job, failed) + } + + private createRunTimeout( + runId: string, + job: CronJob, + sessionId: string + ): ReturnType | null { + const maxDurationMs = job.runtime.maxDurationMs + if (!Number.isFinite(maxDurationMs) || maxDurationMs <= 0) { + return null + } + + return setTimeout(() => { + void this.failRunForTimeout(runId, job, sessionId, maxDurationMs) + }, maxDurationMs) + } + + private clearActiveSession(sessionId: string): void { + const activeSession = this.activeSessions.get(sessionId) + if (activeSession?.timeout) { + clearTimeout(activeSession.timeout) + } + this.activeSessions.delete(sessionId) + } + + private async failRunForTimeout( + runId: string, + job: CronJob, + sessionId: string, + maxDurationMs: number + ): Promise { + const current = this.repository.getRun(runId) + if (!current || current.status !== 'running') { + this.clearActiveSession(sessionId) + return + } + + const errorMessage = `Cron job exceeded max duration (${maxDurationMs} ms).` + console.warn('[CronJobs] Run exceeded max duration:', { + jobId: job.id, + runId, + sessionId, + maxDurationMs + }) + const failed = this.repository.markRunFailed(runId, errorMessage) + this.clearActiveSession(sessionId) + + try { + await this.sessionStarter.cancelSessionRun?.({ + job, + run: current, + sessionId, + reason: errorMessage + }) + } catch (error) { + console.warn('[CronJobs] Failed to cancel timed out run session:', { + jobId: job.id, + runId, + sessionId, + error + }) + } + + await this.deliverRun(job, failed) + } + + private async deliverRun(job: CronJob, run: CronJobRun): Promise { + try { + await this.deliveryRouter?.deliver({ job, run }) + } catch (error) { + console.error('[CronJobs] Failed to deliver run result:', error) + } + } +} diff --git a/src/main/presenter/cronJobs/runtimeResolver.ts b/src/main/presenter/cronJobs/runtimeResolver.ts new file mode 100644 index 000000000..451ef62db --- /dev/null +++ b/src/main/presenter/cronJobs/runtimeResolver.ts @@ -0,0 +1,86 @@ +import type { CronJob, CronJobAgentSnapshot, CronJobStatus } from '@shared/cronJobs' +import type { IConfigPresenter } from '@shared/presenter' +import type { Agent, DeepChatAgentConfig } from '@shared/types/agent-interface' + +export interface CronJobRuntimePlan { + agent: Pick + config: DeepChatAgentConfig | null + snapshot: CronJobAgentSnapshot | null +} + +export class CronJobRuntimeResolver { + constructor( + private readonly configPresenter: Pick< + IConfigPresenter, + 'listAgents' | 'resolveDeepChatAgentConfig' + > + ) {} + + async resolve( + job: Pick< + CronJob, + 'agentId' | 'modelPolicy' | 'toolPolicy' | 'permissionPolicy' | 'agentSnapshot' + > + ): Promise<{ + status: CronJobStatus + plan: CronJobRuntimePlan | null + }> { + const agent = await this.getEnabledAgent(job.agentId) + if (!agent) { + return { status: 'invalid_agent', plan: null } + } + + const shouldUseSnapshot = + job.modelPolicy === 'pin_current' || + job.toolPolicy === 'snapshot' || + job.permissionPolicy === 'snapshot' + const snapshot = shouldUseSnapshot ? job.agentSnapshot : null + return { + status: 'ready', + plan: { + agent, + config: snapshot?.config + ? (snapshot.config as DeepChatAgentConfig) + : await this.resolveConfig(agent), + snapshot + } + } + } + + async captureSnapshot(agentId: string | null): Promise { + const agent = await this.getEnabledAgent(agentId) + if (!agent) { + return null + } + + return { + version: 1, + capturedAt: Date.now(), + agent: { + id: agent.id, + name: agent.name, + type: (agent.agentType ?? agent.type) as 'deepchat' | 'acp' + }, + config: await this.resolveConfig(agent) + } + } + + private async getEnabledAgent(agentId: string | null): Promise { + if (!agentId) { + return null + } + return ( + (await this.configPresenter.listAgents()).find( + (agent) => agent.id === agentId && agent.enabled + ) ?? null + ) + } + + private async resolveConfig( + agent: Pick + ): Promise { + return (agent.agentType ?? agent.type) === 'deepchat' + ? await this.configPresenter.resolveDeepChatAgentConfig(agent.id) + : null + } +} diff --git a/src/main/presenter/cronJobs/schedulerProcessManager.ts b/src/main/presenter/cronJobs/schedulerProcessManager.ts new file mode 100644 index 000000000..3de8db7d5 --- /dev/null +++ b/src/main/presenter/cronJobs/schedulerProcessManager.ts @@ -0,0 +1,405 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import type { UtilityProcess } from 'electron' +import type { CronJobsSchedulerStatus, SchedulerCommand, SchedulerEvent } from '@shared/cronJobs' +import type { CronJobsSchedulerSnapshot } from './repository' + +type SchedulerUtilityProcess = Pick & { + on(event: 'message', listener: (message: unknown) => void): SchedulerUtilityProcess + on(event: 'exit', listener: (code: number) => void): SchedulerUtilityProcess + on(event: 'error', listener: (type: string, location: string) => void): SchedulerUtilityProcess + once(event: 'spawn', listener: () => void): SchedulerUtilityProcess + once(event: 'exit', listener: (code: number) => void): SchedulerUtilityProcess + off(event: 'spawn', listener: () => void): SchedulerUtilityProcess + off(event: 'exit', listener: (code: number) => void): SchedulerUtilityProcess +} + +export interface SchedulerRunDueEvent { + jobId: string + runId: string + scheduledAt: number + reason: 'scheduled' | 'manual' +} + +export interface SchedulerProcessManagerDeps { + dbPath: string + dbPassword?: string + getSnapshot: () => CronJobsSchedulerSnapshot + onRunDue: (event: SchedulerRunDueEvent) => Promise | void + idleShutdownMs?: number + maxRestartAttempts?: number + restartDelayMs?: number + spawnHost?: () => Promise +} + +const DEFAULT_IDLE_SHUTDOWN_MS = 30_000 +const DEFAULT_RESTART_DELAY_MS = 1_000 +const DEFAULT_MAX_RESTART_ATTEMPTS = 5 + +export class SchedulerProcessManager { + private host: SchedulerUtilityProcess | null = null + private hostReady: Promise | null = null + private readonly idleShutdownMs: number + private readonly restartDelayMs: number + private readonly maxRestartAttempts: number + private idleStopTimer: NodeJS.Timeout | null = null + private restartTimer: NodeJS.Timeout | null = null + private shuttingDown = false + private status: CronJobsSchedulerStatus = { + state: 'stopped', + pid: null, + enabledJobCount: 0, + nextRunAt: null, + lastHeartbeatAt: null, + lastError: null, + restartAttempts: 0, + updatedAt: Date.now() + } + + constructor(private readonly deps: SchedulerProcessManagerDeps) { + this.idleShutdownMs = deps.idleShutdownMs ?? DEFAULT_IDLE_SHUTDOWN_MS + this.restartDelayMs = deps.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS + this.maxRestartAttempts = deps.maxRestartAttempts ?? DEFAULT_MAX_RESTART_ATTEMPTS + } + + getStatus(): CronJobsSchedulerStatus { + return { ...this.status } + } + + async start(reason = 'manual'): Promise { + return await this.reconcile(reason) + } + + async reconcile(reason = 'manual'): Promise { + const snapshot = this.deps.getSnapshot() + this.updateStatus({ + enabledJobCount: snapshot.enabledJobCount, + nextRunAt: snapshot.nextRunAt + }) + + if (snapshot.enabledJobCount === 0) { + this.scheduleIdleStop() + if (!this.host) { + this.updateStatus({ state: 'idle' }) + } else { + this.postCommand({ + type: 'RECONCILE', + reason, + now: Date.now() + }) + } + return this.getStatus() + } + + this.cancelIdleStop() + const host = await this.ensureHost() + host.postMessage({ + type: 'RECONCILE', + reason, + now: Date.now() + } satisfies SchedulerCommand) + return this.getStatus() + } + + async restart(): Promise { + await this.stop('restart') + this.shuttingDown = false + this.updateStatus({ restartAttempts: 0, lastError: null }) + return await this.reconcile('restart') + } + + async stop(reason = 'manual'): Promise { + this.shuttingDown = true + this.cancelIdleStop() + this.cancelRestart() + + const host = this.host + this.host = null + this.hostReady = null + if (host) { + try { + host.postMessage({ + type: 'STOP', + reason, + now: Date.now() + } satisfies SchedulerCommand) + } catch {} + + try { + host.kill() + } catch {} + } + + this.updateStatus({ + state: 'stopped', + pid: null + }) + return this.getStatus() + } + + private async ensureHost(): Promise { + if (this.host) { + return this.host + } + if (this.hostReady) { + return await this.hostReady + } + + this.shuttingDown = false + this.updateStatus({ state: 'starting', lastError: null }) + this.hostReady = this.spawnHost() + try { + return await this.hostReady + } finally { + this.hostReady = null + } + } + + private async spawnHost(): Promise { + const host = this.deps.spawnHost ? await this.deps.spawnHost() : await this.spawnDefaultHost() + host.on('message', (message) => this.handleHostMessage(message)) + host.on('exit', (code) => this.handleHostExit(code)) + host.on('error', (type, location) => { + const message = `Scheduler utility process error: ${type} at ${location}` + console.error('[CronJobs] Scheduler utility process error:', { type, location }) + this.updateStatus({ state: 'error', lastError: message }) + }) + + this.host = host + this.updateStatus({ + state: 'running', + pid: host.pid ?? null, + restartAttempts: 0 + }) + host.postMessage({ + type: 'START', + now: Date.now() + } satisfies SchedulerCommand) + return host + } + + private async spawnDefaultHost(): Promise { + const { app, utilityProcess } = await import('electron') + const modulePath = this.resolveUtilityHostEntryPoint(app.getAppPath()) + const host = utilityProcess.fork(modulePath, ['--deepchat-cron-scheduler-host'], { + serviceName: 'DeepChat Cron Jobs Scheduler', + stdio: 'ignore', + env: { + ...process.env, + DEEPCHAT_CRON_SCHEDULER_HOST: '1', + DEEPCHAT_CRON_SCHEDULER_DB_PATH: this.deps.dbPath, + ...(this.deps.dbPassword + ? { DEEPCHAT_CRON_SCHEDULER_DB_PASSWORD: this.deps.dbPassword } + : {}) + } + }) as SchedulerUtilityProcess + + return await new Promise((resolve, reject) => { + let settled = false + const settle = (callback: () => void) => { + if (settled) { + return + } + settled = true + host.off('spawn', onSpawn) + host.off('exit', onExit) + callback() + } + const onSpawn = () => { + settle(() => resolve(host)) + } + const onExit = (code: number) => { + settle(() => reject(new Error(`Cron scheduler utility exited before spawn: ${code}`))) + } + + host.once('spawn', onSpawn) + host.once('exit', onExit) + }) + } + + private resolveUtilityHostEntryPoint(appPath?: string): string { + const modulePath = fileURLToPath(import.meta.url) + const candidates = [ + ...(appPath + ? [ + path.join(appPath, 'out/main/schedulerUtilityHost.js'), + path.join(appPath, 'schedulerUtilityHost.js') + ] + : []), + path.resolve(path.dirname(modulePath), 'schedulerUtilityHost.js'), + path.resolve(path.dirname(modulePath), '../schedulerUtilityHost.js'), + path.resolve(process.cwd(), 'out/main/schedulerUtilityHost.js') + ] + + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0] + } + + private handleHostMessage(message: unknown): void { + const event = unwrapSchedulerEvent(message) + if (!event) { + return + } + + switch (event.type) { + case 'READY': + this.updateStatus({ + state: 'running', + pid: event.pid, + lastHeartbeatAt: event.now, + lastError: null + }) + return + case 'HEARTBEAT': + this.updateStatus({ + state: 'running', + enabledJobCount: event.enabledJobCount, + nextRunAt: event.nextRunAt, + lastHeartbeatAt: event.now, + lastError: null + }) + if (event.enabledJobCount === 0) { + this.scheduleIdleStop() + } + return + case 'IDLE': + this.updateStatus({ + state: 'idle', + enabledJobCount: event.enabledJobCount, + nextRunAt: event.nextRunAt, + lastHeartbeatAt: event.now + }) + this.scheduleIdleStop() + return + case 'RUN_DUE': + console.info('[CronJobs] Scheduler reported due run:', { + jobId: event.jobId, + runId: event.runId, + scheduledAt: event.scheduledAt, + reason: event.reason + }) + void Promise.resolve( + this.deps.onRunDue({ + jobId: event.jobId, + runId: event.runId, + scheduledAt: event.scheduledAt, + reason: event.reason + }) + ).catch((error) => { + console.error('[CronJobs] Failed to process due run:', error) + }) + return + case 'ERROR': + console.error('[CronJobs] Scheduler utility error:', event.message) + this.updateStatus({ + state: 'error', + lastError: event.message, + lastHeartbeatAt: event.now + }) + return + } + } + + private handleHostExit(code: number): void { + const message = `Cron scheduler utility exited with code ${code}.` + const hasEnabledJobs = this.deps.getSnapshot().enabledJobCount > 0 + const expectedExit = this.shuttingDown || !hasEnabledJobs + this.host = null + this.hostReady = null + this.updateStatus({ + state: expectedExit ? (this.shuttingDown ? 'stopped' : 'idle') : 'error', + pid: null, + lastError: expectedExit ? null : message + }) + + if (expectedExit) { + return + } + this.scheduleRestart() + } + + private postCommand(command: SchedulerCommand): void { + try { + this.host?.postMessage(command) + } catch (error) { + console.error('[CronJobs] Failed to post scheduler command:', error) + } + } + + private scheduleIdleStop(): void { + if (this.idleStopTimer || !this.host) { + return + } + this.idleStopTimer = setTimeout(() => { + this.idleStopTimer = null + if (this.deps.getSnapshot().enabledJobCount === 0) { + void this.stop('idle') + } + }, this.idleShutdownMs) + } + + private cancelIdleStop(): void { + if (!this.idleStopTimer) { + return + } + clearTimeout(this.idleStopTimer) + this.idleStopTimer = null + } + + private scheduleRestart(): void { + if (this.restartTimer || this.status.restartAttempts >= this.maxRestartAttempts) { + return + } + + const nextAttempts = this.status.restartAttempts + 1 + this.updateStatus({ restartAttempts: nextAttempts }) + this.restartTimer = setTimeout(() => { + this.restartTimer = null + void this.reconcile('restart-after-exit').catch((error) => { + const message = error instanceof Error ? error.message : String(error) + this.updateStatus({ state: 'error', lastError: message }) + this.scheduleRestart() + }) + }, this.restartDelayMs) + } + + private cancelRestart(): void { + if (!this.restartTimer) { + return + } + clearTimeout(this.restartTimer) + this.restartTimer = null + } + + private updateStatus(patch: Partial): void { + this.status = { + ...this.status, + ...patch, + updatedAt: Date.now() + } + } +} + +function unwrapSchedulerEvent(message: unknown): SchedulerEvent | null { + const payload = + message && typeof message === 'object' && 'data' in message + ? (message as { data?: unknown }).data + : message + + if (!payload || typeof payload !== 'object' || !('type' in payload)) { + return null + } + + const type = (payload as { type?: unknown }).type + if ( + type === 'READY' || + type === 'HEARTBEAT' || + type === 'RUN_DUE' || + type === 'IDLE' || + type === 'ERROR' + ) { + return payload as SchedulerEvent + } + + return null +} diff --git a/src/main/presenter/cronJobs/schedulerUtilityHost.ts b/src/main/presenter/cronJobs/schedulerUtilityHost.ts new file mode 100644 index 000000000..038026118 --- /dev/null +++ b/src/main/presenter/cronJobs/schedulerUtilityHost.ts @@ -0,0 +1,470 @@ +import { randomUUID } from 'node:crypto' +import type Database from 'better-sqlite3-multiple-ciphers' +import { openSQLiteDatabase } from '../sqlitePresenter/databaseConnection' +import type { + CronJobMisfirePolicy, + CronJobRunReason, + SchedulerCommand, + SchedulerEvent +} from '@shared/cronJobs' +import { CronExpressionService } from './cronExpressionService' + +const SCHEDULER_HOST_ARG = '--deepchat-cron-scheduler-host' +const HEARTBEAT_INTERVAL_MS = 5_000 +const MAX_SCAN_DELAY_MS = 30_000 +const DUE_SCAN_LIMIT = 100 + +type ParentPort = { + postMessage(message: unknown): void + on(event: 'message', listener: (message: unknown) => void): void + start?(): void +} + +type ParentPortMessageEvent = { + data?: unknown +} + +interface DueCronJobRow { + id: string + cron_expr: string + timezone: string + next_run_at: number + misfire_policy: CronJobMisfirePolicy + max_catch_up_runs: number | null +} + +interface SchedulerSnapshot { + enabledJobCount: number + nextRunAt: number | null +} + +function getParentPort(): ParentPort | null { + const maybeProcess = process as NodeJS.Process & { + parentPort?: ParentPort + } + return maybeProcess.parentPort ?? null +} + +function isSchedulerHostRequest(): boolean { + return ( + process.env.DEEPCHAT_CRON_SCHEDULER_HOST === '1' || process.argv.includes(SCHEDULER_HOST_ARG) + ) +} + +function getParentPortMessagePayload(message: unknown): unknown { + if (isSchedulerCommand(message)) { + return message + } + + if (message && typeof message === 'object' && 'data' in message) { + return (message as ParentPortMessageEvent).data + } + + return message +} + +function isSchedulerCommand(message: unknown): message is SchedulerCommand { + if (!message || typeof message !== 'object') { + return false + } + + const type = (message as { type?: unknown }).type + return type === 'START' || type === 'RECONCILE' || type === 'RUN_NOW' || type === 'STOP' +} + +function serializeError(error: unknown): { message: string; stack?: string } { + if (error instanceof Error) { + return { + message: error.message, + stack: error.stack + } + } + return { message: String(error) } +} + +export class CronJobsSchedulerUtilityHost { + private db: Database.Database | null = null + private heartbeatTimer: NodeJS.Timeout | null = null + private scanTimer: NodeJS.Timeout | null = null + private shuttingDown = false + private readonly scheduleService = new CronExpressionService() + + constructor( + private readonly options: { + dbPath: string + dbPassword?: string + postMessage: (message: SchedulerEvent) => void + } + ) {} + + start(): void { + if (this.shuttingDown) { + return + } + try { + this.openDatabase() + this.send({ + type: 'READY', + pid: process.pid || null, + now: Date.now() + }) + this.startHeartbeat() + this.scanDueRuns() + } catch (error) { + this.reportError(error) + } + } + + reconcile(): void { + try { + this.openDatabase() + this.scanDueRuns() + } catch (error) { + this.reportError(error) + } + } + + runNow(jobId: string, now = Date.now()): void { + try { + this.openDatabase() + const db = this.requireDatabase() + const job = db.prepare('SELECT id FROM cron_jobs WHERE id = ?').get(jobId) as + | { id: string } + | undefined + if (!job) { + return + } + + const run = this.queueRun(job.id, now, 'manual') + if (!run) { + return + } + this.send({ + type: 'RUN_DUE', + jobId: job.id, + runId: run.runId, + scheduledAt: now, + reason: 'manual', + now: Date.now() + }) + this.scheduleNextScan() + } catch (error) { + this.reportError(error) + } + } + + shutdown(): void { + this.shuttingDown = true + this.clearHeartbeat() + this.clearScanTimer() + if (this.db) { + try { + this.db.close() + } catch {} + this.db = null + } + } + + private openDatabase(): void { + if (this.db) { + return + } + this.db = openSQLiteDatabase(this.options.dbPath, this.options.dbPassword) + } + + private requireDatabase(): Database.Database { + if (!this.db) { + throw new Error('Cron scheduler database is not open.') + } + return this.db + } + + private startHeartbeat(): void { + this.clearHeartbeat() + this.sendHeartbeat() + this.heartbeatTimer = setInterval(() => { + this.sendHeartbeat() + }, HEARTBEAT_INTERVAL_MS) + } + + private clearHeartbeat(): void { + if (!this.heartbeatTimer) { + return + } + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + + private scanDueRuns(): void { + if (this.shuttingDown) { + return + } + + try { + const db = this.requireDatabase() + const now = Date.now() + const dueRows = db + .prepare( + `SELECT id, + cron_expr, + timezone, + next_run_at, + misfire_policy, + max_catch_up_runs + FROM cron_jobs + WHERE enabled = 1 + AND status = 'ready' + AND agent_id IS NOT NULL + AND task_prompt != '' + AND next_run_at IS NOT NULL + AND next_run_at <= ? + ORDER BY next_run_at ASC, id ASC + LIMIT ?` + ) + .all(now, DUE_SCAN_LIMIT) as DueCronJobRow[] + + const dueEvents = db.transaction((rows: DueCronJobRow[]) => + rows.flatMap((row) => { + const reconciliation = this.scheduleService.reconcileDueRun( + { + cronExpr: row.cron_expr, + timezone: row.timezone, + misfirePolicy: row.misfire_policy, + maxCatchUpRuns: row.max_catch_up_runs + }, + row.next_run_at, + now + ) + + if (reconciliation.error) { + db.prepare( + `UPDATE cron_jobs + SET next_run_at = NULL, + schedule_error = ?, + updated_at = ? + WHERE id = ?` + ).run(reconciliation.error, now, row.id) + return [] + } + + const events = reconciliation.scheduledAts.flatMap((scheduledAt) => { + const run = this.queueRun(row.id, scheduledAt, 'scheduled', true) + return run + ? [ + { + type: 'RUN_DUE' as const, + jobId: row.id, + runId: run.runId, + scheduledAt, + reason: 'scheduled' as const, + now: Date.now() + } + ] + : [] + }) + + db.prepare( + `UPDATE cron_jobs + SET next_run_at = ?, + schedule_error = NULL, + updated_at = ? + WHERE id = ? + AND next_run_at = ?` + ).run(reconciliation.nextRunAt, now, row.id, row.next_run_at) + + return events + }) + )(dueRows) + + for (const event of dueEvents) { + this.send(event) + } + this.sendHeartbeat() + } catch (error) { + this.reportError(error) + } finally { + this.scheduleNextScan() + } + } + + private queueRun( + jobId: string, + scheduledAt: number, + reason: CronJobRunReason, + ignoreDuplicate = false + ): { runId: string } | null { + const db = this.requireDatabase() + const now = Date.now() + const runId = randomUUID() + const statement = ignoreDuplicate + ? `INSERT OR IGNORE INTO cron_job_runs ( + id, + job_id, + scheduled_at, + queued_at, + started_at, + completed_at, + status, + reason, + error, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, NULL, NULL, 'queued', ?, NULL, ?, ?)` + : `INSERT INTO cron_job_runs ( + id, + job_id, + scheduled_at, + queued_at, + started_at, + completed_at, + status, + reason, + error, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, NULL, NULL, 'queued', ?, NULL, ?, ?)` + + const result = db.prepare(statement).run(runId, jobId, scheduledAt, now, reason, now, now) + + return result.changes > 0 ? { runId } : null + } + + private scheduleNextScan(): void { + if (this.shuttingDown) { + return + } + + this.clearScanTimer() + const snapshot = this.readSnapshot() + if (snapshot.enabledJobCount === 0) { + this.send({ + type: 'IDLE', + enabledJobCount: snapshot.enabledJobCount, + nextRunAt: snapshot.nextRunAt, + now: Date.now() + }) + return + } + + const delay = + snapshot.nextRunAt === null + ? MAX_SCAN_DELAY_MS + : Math.min(Math.max(snapshot.nextRunAt - Date.now(), 0), MAX_SCAN_DELAY_MS) + this.scanTimer = setTimeout(() => { + this.scanTimer = null + this.scanDueRuns() + }, delay) + } + + private clearScanTimer(): void { + if (!this.scanTimer) { + return + } + clearTimeout(this.scanTimer) + this.scanTimer = null + } + + private sendHeartbeat(): void { + try { + const snapshot = this.readSnapshot() + this.send({ + type: 'HEARTBEAT', + enabledJobCount: snapshot.enabledJobCount, + nextRunAt: snapshot.nextRunAt, + now: Date.now() + }) + } catch (error) { + this.reportError(error) + } + } + + private readSnapshot(): SchedulerSnapshot { + const db = this.requireDatabase() + const countRow = db + .prepare('SELECT COUNT(*) AS count FROM cron_jobs WHERE enabled = 1') + .get() as { count: number } | undefined + const nextRow = db + .prepare( + `SELECT MIN(next_run_at) AS next_run_at + FROM cron_jobs + WHERE enabled = 1 + AND next_run_at IS NOT NULL` + ) + .get() as { next_run_at: number | null } | undefined + + return { + enabledJobCount: countRow?.count ?? 0, + nextRunAt: nextRow?.next_run_at ?? null + } + } + + private send(message: SchedulerEvent): void { + this.options.postMessage(message) + } + + private reportError(error: unknown): void { + const serialized = serializeError(error) + this.send({ + type: 'ERROR', + message: serialized.message, + stack: serialized.stack, + now: Date.now() + }) + } +} + +export function runSchedulerUtilityHostIfRequested(): boolean { + if (!isSchedulerHostRequest()) { + return false + } + + const parentPort = getParentPort() + if (!parentPort) { + throw new Error('Cron scheduler utility host started without a parent port.') + } + + const dbPath = process.env.DEEPCHAT_CRON_SCHEDULER_DB_PATH + if (!dbPath) { + throw new Error('Cron scheduler utility host started without a database path.') + } + + const host = new CronJobsSchedulerUtilityHost({ + dbPath, + dbPassword: process.env.DEEPCHAT_CRON_SCHEDULER_DB_PASSWORD, + postMessage: (message) => parentPort.postMessage(message) + }) + const keepAliveIntervalId = setInterval(() => {}, 2 ** 31 - 1) + parentPort.start?.() + + parentPort.on('message', (message) => { + const command = getParentPortMessagePayload(message) + if (!isSchedulerCommand(command)) { + return + } + + switch (command.type) { + case 'START': + host.start() + return + case 'RECONCILE': + host.reconcile() + return + case 'RUN_NOW': + host.runNow(command.jobId, command.now) + return + case 'STOP': + clearInterval(keepAliveIntervalId) + host.shutdown() + process.exit(0) + } + }) + + process.once('beforeExit', () => { + clearInterval(keepAliveIntervalId) + host.shutdown() + }) + + return true +} diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index c01e1fc1c..0bb5669a6 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -65,7 +65,7 @@ import type { SkillSessionStatePort } from './skillPresenter' import { SkillSyncPresenter } from './skillSyncPresenter' import { HooksNotificationsService } from './hooksNotifications' import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge' -import { ScheduledTasksService } from './scheduledTasks' +import { CronJobsService } from './cronJobs' import { AgentSessionPresenter } from './agentSessionPresenter' import { AgentRuntimePresenter } from './agentRuntimePresenter' import { MemoryPresenter, isSafeAgentId } from './memoryPresenter' @@ -146,7 +146,7 @@ export class Presenter implements IPresenter { pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter hooksNotifications: HooksNotificationsService - scheduledTasks: ScheduledTasksService + cronJobs: CronJobsService commandPermissionService: CommandPermissionService filePermissionService: FilePermissionService settingsPermissionService: SettingsPermissionService @@ -336,6 +336,15 @@ export class Presenter implements IPresenter { }, forgetMemory: async (agentId, memoryId) => await this.memoryPresenter.forgetMemory(agentId, memoryId), + listCronJobs: async () => await this.cronJobs.list(), + upsertCronJob: async (input) => (await this.cronJobs.upsert(input)).job, + deleteCronJob: async (id) => { + await this.cronJobs.delete(id) + }, + toggleCronJob: async (id, enabled) => (await this.cronJobs.toggle(id, enabled)).job, + runCronJobNow: async (id) => (await this.cronJobs.runNow(id)).run, + listCronJobRuns: async (jobId, limit) => this.cronJobs.listRuns(jobId, limit), + previewCronSchedule: async (input) => this.cronJobs.previewSchedule(input), createSubagentSession: async (input) => { const agentSessionPresenter = this.agentSessionPresenter as IAgentSessionPresenter & { createSubagentSession?: (createInput: typeof input) => Promise<{ @@ -473,10 +482,9 @@ export class Presenter implements IPresenter { getSession: async () => null, getMessage: async () => null }) - this.scheduledTasks = new ScheduledTasksService({ - configPresenter: this.configPresenter, - notificationPresenter: this.notificationPresenter, - windowPresenter: this.windowPresenter + this.cronJobs = new CronJobsService({ + sqlitePresenter: this.sqlitePresenter as unknown as SQLitePresenter, + configPresenter: this.configPresenter }) const newSessionHooksBridge = new NewSessionHooksBridge(this.hooksNotifications) const providerCatalogPort: ProviderCatalogPort = { @@ -661,6 +669,7 @@ export class Presenter implements IPresenter { tabPresenter: this.tabPresenter }) this.remoteControlPresenter = this.#remoteControlPresenter + this.cronJobs.setRemoteDeliveryPort(this.#remoteControlPresenter) // Update hooksNotifications with actual dependencies now that agentSessionPresenter is ready this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { @@ -952,6 +961,12 @@ export class Presenter implements IPresenter { } async destroy(): Promise { + try { + await this.cronJobs.stop() + } catch (error) { + console.error('CronJobsService.stop failed during presenter destroy:', error) + } + try { await this.pluginPresenter.shutdown() } catch (error) { @@ -1024,7 +1039,7 @@ const buildMainKernelRouteRuntime = () => pluginPresenter: presenter.pluginPresenter, databaseSecurityPresenter: presenter.databaseSecurityPresenter, memoryPresenter: presenter.memoryPresenter, - scheduledTasks: presenter.scheduledTasks + cronJobs: presenter.cronJobs }) export function getMainKernelRouteRuntime(): ReturnType { diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts new file mode 100644 index 000000000..ae3bf09ba --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts @@ -0,0 +1,28 @@ +import logger from '@shared/logger' +import { LifecycleHook, LifecycleContext } from '@shared/presenter' +import { presenter, getMainKernelRouteRuntime } from '@/presenter' +import { LifecyclePhase } from '@shared/lifecycle' + +export const cronJobsStartHook: LifecycleHook = { + name: 'cron-jobs-start', + phase: LifecyclePhase.AFTER_START, + priority: 21, + critical: false, + execute: async (_context: LifecycleContext) => { + if (!presenter) { + throw new Error('cronJobsStartHook: Presenter not initialized') + } + + try { + getMainKernelRouteRuntime() + } catch (error) { + console.warn( + '[cronJobsStartHook] Failed to prime route runtime; cron jobs cannot create sessions until routes initialize:', + error + ) + } + + presenter.cronJobs.start() + logger.info('cronJobsStartHook: Scheduler reconciled') + } +} diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts deleted file mode 100644 index 5aba74035..000000000 --- a/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts +++ /dev/null @@ -1,39 +0,0 @@ -import logger from '@shared/logger' -/** - * Scheduled tasks start hook for after-start phase - * - * The route runtime owns the wiring between the scheduled tasks service and - * the session service (for auto-send actions), so we force its construction - * by reading any route runtime via getRuntime, then call `start()` so the - * scheduler arms timers and backfills missed one-shot tasks. - */ - -import { LifecycleHook, LifecycleContext } from '@shared/presenter' -import { presenter, getMainKernelRouteRuntime } from '@/presenter' -import { LifecyclePhase } from '@shared/lifecycle' - -export const scheduledTasksStartHook: LifecycleHook = { - name: 'scheduled-tasks-start', - phase: LifecyclePhase.AFTER_START, - priority: 20, - critical: false, - execute: async (_context: LifecycleContext) => { - if (!presenter) { - throw new Error('scheduledTasksStartHook: Presenter not initialized') - } - - // Touch the route runtime so the session creator gets wired up before - // the scheduler fires anything. - try { - getMainKernelRouteRuntime() - } catch (error) { - console.warn( - '[scheduledTasksStartHook] Failed to prime route runtime; auto-send may degrade to draft mode:', - error - ) - } - - presenter.scheduledTasks.start() - logger.info('scheduledTasksStartHook: Scheduler started') - } -} diff --git a/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/cronJobsStopHook.ts similarity index 53% rename from src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts rename to src/main/presenter/lifecyclePresenter/hooks/beforeQuit/cronJobsStopHook.ts index 6012c92a7..0e0ab51ca 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/cronJobsStopHook.ts @@ -1,21 +1,16 @@ -/** - * Scheduled tasks stop hook for beforeQuit phase - * Cancels all armed timers so the scheduler does not fire during shutdown. - */ - import { LifecycleHook, LifecycleContext } from '@shared/presenter' import { presenter } from '@/presenter' import { LifecyclePhase } from '@shared/lifecycle' -export const scheduledTasksStopHook: LifecycleHook = { - name: 'scheduled-tasks-stop', +export const cronJobsStopHook: LifecycleHook = { + name: 'cron-jobs-stop', phase: LifecyclePhase.BEFORE_QUIT, - priority: 30, + priority: 29, critical: false, execute: async (_context: LifecycleContext) => { if (!presenter) { return } - presenter.scheduledTasks.stop() + await presenter.cronJobs.stop() } } diff --git a/src/main/presenter/lifecyclePresenter/hooks/index.ts b/src/main/presenter/lifecyclePresenter/hooks/index.ts index 95296d94f..9482e6596 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/index.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/index.ts @@ -16,7 +16,7 @@ export { rtkHealthCheckHook } from './after-start/rtkHealthCheckHook' export { usageStatsBackfillHook } from './after-start/usageStatsBackfillHook' export { sqliteMainlineNormalizationHook } from './after-start/sqliteMainlineNormalizationHook' export { disabledSearchToolCleanupHook } from './after-start/disabledSearchToolCleanupHook' -export { scheduledTasksStartHook } from './after-start/scheduledTasksStartHook' +export { cronJobsStartHook } from './after-start/cronJobsStartHook' export { memoryMaintenanceStartHook } from './after-start/memoryMaintenanceStartHook' export { mcpShutdownHook } from './beforeQuit/mcpShutdownHook' export { trayDestroyHook } from './beforeQuit/trayDestroyHook' @@ -25,4 +25,4 @@ export { presenterDestroyHook } from './beforeQuit/presenterDestroyHook' export { builtinKnowledgeDestroyHook } from './beforeQuit/builtinKnowledgeDestroyHook' export { windowQuittingHook } from './beforeQuit/windowQuittingHook' export { acpCleanupHook } from './beforeQuit/acpCleanupHook' -export { scheduledTasksStopHook } from './beforeQuit/scheduledTasksStopHook' +export { cronJobsStopHook } from './beforeQuit/cronJobsStopHook' diff --git a/src/main/presenter/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index 079be681b..1ccbb373f 100644 --- a/src/main/presenter/mcpPresenter/index.ts +++ b/src/main/presenter/mcpPresenter/index.ts @@ -89,6 +89,22 @@ export class McpPresenter implements IMCPPresenter { eventBus.sendToMain(MCP_EVENTS.INITIALIZED) } + private startServerInBackground( + serverName: string, + successMessage: string, + failureMessage: string + ): void { + void this.serverManager + .startServer(serverName) + .then(() => { + logger.info(successMessage) + this.emitServerStarted(serverName) + }) + .catch((error) => { + console.error(failureMessage, error) + }) + } + constructor(configPresenter?: IConfigPresenter, cacheImage?: (data: string) => Promise) { logger.info('Initializing MCP Presenter') @@ -167,36 +183,32 @@ export class McpPresenter implements IMCPPresenter { // Check and start deepchat-inmemory/custom-prompts-server const customPromptsServerName = 'deepchat-inmemory/custom-prompts-server' + const startingServers = new Set() if (mcpEnabled && servers[customPromptsServerName]) { logger.info(`[MCP] Attempting to start custom prompts server: ${customPromptsServerName}`) - - try { - await this.serverManager.startServer(customPromptsServerName) - logger.info(`[MCP] Custom prompts server ${customPromptsServerName} started successfully`) - - this.emitServerStarted(customPromptsServerName) - } catch (error) { - console.error( - `[MCP] Failed to start custom prompts server ${customPromptsServerName}:`, - error - ) - } + startingServers.add(customPromptsServerName) + this.startServerInBackground( + customPromptsServerName, + `[MCP] Custom prompts server ${customPromptsServerName} started successfully`, + `[MCP] Failed to start custom prompts server ${customPromptsServerName}:` + ) } if (enabledServers.length > 0) { for (const serverName of enabledServers) { const serverConfig = servers[serverName] - if (serverConfig && (mcpEnabled || this.isPluginOwnedServerConfig(serverConfig))) { + if ( + serverConfig && + !startingServers.has(serverName) && + (mcpEnabled || this.isPluginOwnedServerConfig(serverConfig)) + ) { logger.info(`[MCP] Attempting to start enabled server: ${serverName}`) - - try { - await this.serverManager.startServer(serverName) - logger.info(`[MCP] Enabled server ${serverName} started successfully`) - - this.emitServerStarted(serverName) - } catch (error) { - console.error(`[MCP] Failed to start enabled server ${serverName}:`, error) - } + startingServers.add(serverName) + this.startServerInBackground( + serverName, + `[MCP] Enabled server ${serverName} started successfully`, + `[MCP] Failed to start enabled server ${serverName}:` + ) } } } diff --git a/src/main/presenter/pluginPresenter/index.ts b/src/main/presenter/pluginPresenter/index.ts index 81f38dcf5..f20a4840c 100644 --- a/src/main/presenter/pluginPresenter/index.ts +++ b/src/main/presenter/pluginPresenter/index.ts @@ -1595,7 +1595,13 @@ export class PluginPresenter { for (const serverName of serverNames) { try { if (!(await this.mcpPresenter.isServerRunning(serverName))) { - await this.mcpPresenter.startServer(serverName) + void this.mcpPresenter.startServer(serverName).catch((error) => { + console.warn('[PluginHost] Failed to auto-start plugin MCP server:', { + pluginId, + serverName, + error + }) + }) } } catch (error) { console.warn('[PluginHost] Failed to auto-start plugin MCP server:', { diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index 5a95ef097..0f0359854 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron' import { randomBytes } from 'node:crypto' import * as http from 'node:http' import logger from '@shared/logger' +import type { CronJob, CronJobDeliveryTarget, CronJobRun } from '@shared/cronJobs' import type { ChannelSettingsMap, DiscordPairingSnapshot, @@ -47,7 +48,9 @@ import { normalizeQQBotSettingsInput, normalizeTelegramSettingsInput, normalizeWeixinIlinkSettingsInput, + parseDiscordEndpointKey, parseTelegramEndpointKey, + parseWeixinIlinkEndpointKey, type DiscordRuntimeStatusSnapshot, type FeishuRuntimeStatusSnapshot, type QQBotRuntimeStatusSnapshot, @@ -85,6 +88,20 @@ const WEIXIN_TRACE_LOG_ENABLED = process.env.DEEPCHAT_WEIXIN_TRACE === '1' const FEISHU_AUTH_SESSION_TTL_MS = 5 * 60 * 1000 const FEISHU_AUTH_DEFAULT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 const FEISHU_INSTALL_DEFAULT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 +const REMOTE_DELIVERY_DEFAULT_MESSAGE_LIMIT = 4000 +const DISCORD_REMOTE_DELIVERY_MESSAGE_LIMIT = 1900 +const REMOTE_DELIVERY_CHANNELS: readonly RemoteChannel[] = [ + 'telegram', + 'feishu', + 'discord', + 'weixin-ilink' +] + +type CronJobRemoteDeliveryInput = { + job: CronJob + run: CronJobRun + target: Extract +} type FeishuAuthSessionState = { sessionKey: string @@ -420,6 +437,32 @@ export class RemoteControlPresenter { .sort((left, right) => right.updatedAt - left.updatedAt) } + async deliverCronJobResult( + input: CronJobRemoteDeliveryInput + ): Promise<{ remoteMessageId?: string | null }> { + return this.enqueueRuntimeOperation(async () => { + const channel = this.parseRemoteDeliveryChannel(input.target.remoteId) + const binding = (await this.getChannelBindings(channel)).find( + (entry) => entry.endpointKey === input.target.channelId + ) + if (!binding) { + throw new Error(`Remote binding is not available: ${input.target.channelId}`) + } + + const adapter = this.getRemoteDeliveryAdapter(channel, input.target.channelId) + if (!adapter?.connected) { + throw new Error(`Remote channel is not running: ${channel}`) + } + + await adapter.sendMessage( + this.getRemoteDeliveryChatId(channel, input.target.channelId, binding), + this.buildCronJobDeliveryText(input, channel) + ) + + return { remoteMessageId: null } + }) + } + async removeChannelBinding(channel: RemoteChannel, endpointKey: string): Promise { if (!endpointKey.startsWith(`${channel}:`)) { return @@ -1224,6 +1267,87 @@ export class RemoteControlPresenter { }) } + private parseRemoteDeliveryChannel(remoteId: string): RemoteChannel { + if (REMOTE_DELIVERY_CHANNELS.includes(remoteId as RemoteChannel)) { + return remoteId as RemoteChannel + } + + throw new Error(`Unsupported remote delivery channel: ${remoteId}`) + } + + private getRemoteDeliveryAdapter(channel: RemoteChannel, endpointKey: string) { + if (channel === 'weixin-ilink') { + const endpoint = parseWeixinIlinkEndpointKey(endpointKey) + if (!endpoint) { + throw new Error(`Invalid Weixin iLink binding: ${endpointKey}`) + } + return this.channelManager.getAdapter(channel, endpoint.accountId) + } + + return this.channelManager.getAdapter(channel, DEFAULT_CHANNEL_ID) + } + + private getRemoteDeliveryChatId( + channel: RemoteChannel, + endpointKey: string, + binding: RemoteBindingSummary + ): string { + if (channel === 'telegram' || channel === 'feishu') { + return binding.threadId ? `${binding.chatId}:${binding.threadId}` : binding.chatId + } + + if (channel === 'discord') { + const endpoint = parseDiscordEndpointKey(endpointKey) + if (!endpoint) { + throw new Error(`Invalid Discord binding: ${endpointKey}`) + } + return `${endpoint.chatType}:${endpoint.chatId}` + } + + const endpoint = parseWeixinIlinkEndpointKey(endpointKey) + if (!endpoint) { + throw new Error(`Invalid Weixin iLink binding: ${endpointKey}`) + } + return endpoint.userId + } + + private buildCronJobDeliveryText( + input: CronJobRemoteDeliveryInput, + channel: RemoteChannel + ): string { + const body = (input.run.error || input.run.outputPreview || '').trim() + const lines = [`Scheduled task "${input.job.name}" ${input.run.status}.`] + + if (body) { + lines.push('', body) + } + + if (input.target.mode === 'full') { + lines.push( + '', + `Run ID: ${input.run.id}`, + `Scheduled at: ${new Date(input.run.scheduledAt).toISOString()}` + ) + if (input.run.sessionId) { + lines.push(`Session ID: ${input.run.sessionId}`) + } + } + + const text = lines.join('\n') + const limit = this.getRemoteDeliveryMessageLimit(channel) + return limit === null ? text : text.slice(0, limit) + } + + private getRemoteDeliveryMessageLimit(channel: RemoteChannel): number | null { + if (channel === 'feishu') { + return null + } + if (channel === 'discord') { + return DISCORD_REMOTE_DELIVERY_MESSAGE_LIMIT + } + return REMOTE_DELIVERY_DEFAULT_MESSAGE_LIMIT + } + private registerBuiltInFactories(): void { this.channelManager.registerFactory({ source: 'builtin', @@ -2599,9 +2723,12 @@ export class RemoteControlPresenter { : this.bindingStore.getWeixinIlinkDefaultAgentId() } - private enqueueRuntimeOperation(operation: () => Promise): Promise { + private enqueueRuntimeOperation(operation: () => Promise): Promise { const nextOperation = this.runtimeOperation.then(operation, operation) - this.runtimeOperation = nextOperation.catch(() => {}) + this.runtimeOperation = nextOperation.then( + () => undefined, + () => undefined + ) return nextOperation } diff --git a/src/main/presenter/remoteControlPresenter/interface.ts b/src/main/presenter/remoteControlPresenter/interface.ts index 656847de0..d59e36901 100644 --- a/src/main/presenter/remoteControlPresenter/interface.ts +++ b/src/main/presenter/remoteControlPresenter/interface.ts @@ -12,6 +12,7 @@ import type { WeixinIlinkRemoteSettings } from '@shared/presenter' import type { AgentRuntimePresenter } from '../agentRuntimePresenter' +import type { CronJobRemoteDeliveryPort } from '../cronJobs/deliveryRouter' export interface RemoteControlPresenterDeps { configPresenter: IConfigPresenter @@ -28,7 +29,7 @@ export interface RemoteRuntimeLifecycle { } export interface RemoteControlPresenterLike - extends IRemoteControlPresenter, RemoteRuntimeLifecycle { + extends IRemoteControlPresenter, RemoteRuntimeLifecycle, CronJobRemoteDeliveryPort { buildTelegramSettingsSnapshot(): TelegramRemoteSettings buildFeishuSettingsSnapshot(): FeishuRemoteSettings buildQQBotSettingsSnapshot(): QQBotRemoteSettings diff --git a/src/main/presenter/scheduledTasks/index.ts b/src/main/presenter/scheduledTasks/index.ts deleted file mode 100644 index aad36b2d5..000000000 --- a/src/main/presenter/scheduledTasks/index.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { randomUUID } from 'node:crypto' -import log from 'electron-log' -import type { IConfigPresenter, INotificationPresenter, IWindowPresenter } from '@shared/presenter' -import { DEEPLINK_EVENTS } from '@/events' -import { - SCHEDULED_TASKS_VERSION, - SCHEDULED_TASK_DEFAULT_AGENT_ID, - type ScheduledTask, - type ScheduledTaskAction, - type ScheduledTasksSettings -} from '@shared/scheduledTasks' -import type { z } from 'zod' -import { - scheduledTaskActionSchema, - scheduledTaskTriggerSchema, - type scheduledTasksUpsertInputSchema -} from '@shared/contracts/routes/scheduledTasks.routes' -import { computeNextFireAt, shouldBackfillOneShot } from './normalize' - -const MAX_TIMEOUT_MS = 12 * 60 * 60 * 1000 // 12h chained-timeout cap -const RECENT_DRIFT_TOLERANCE_MS = 60 * 1000 // forgive up to 1m clock drift - -export type ScheduledTasksUpsertInput = z.input - -interface SessionCreator { - createSessionForTask(input: { - agentId: string - message: string - providerId?: string - modelId?: string - systemPrompt?: string - }): Promise<{ sessionId: string | null }> -} - -export interface ScheduledTasksServiceDeps { - configPresenter: Pick< - IConfigPresenter, - 'getScheduledTasksConfig' | 'setScheduledTasksConfig' | 'getNotificationsEnabled' - > - notificationPresenter: Pick - windowPresenter: Pick & { - mainWindow: IWindowPresenter['mainWindow'] - } - sessionCreator?: SessionCreator -} - -export class ScheduledTasksService { - private readonly configPresenter: ScheduledTasksServiceDeps['configPresenter'] - private readonly notificationPresenter: ScheduledTasksServiceDeps['notificationPresenter'] - private readonly windowPresenter: ScheduledTasksServiceDeps['windowPresenter'] - private sessionCreator: SessionCreator | null - private readonly timers = new Map() - private started = false - - constructor(deps: ScheduledTasksServiceDeps) { - this.configPresenter = deps.configPresenter - this.notificationPresenter = deps.notificationPresenter - this.windowPresenter = deps.windowPresenter - this.sessionCreator = deps.sessionCreator ?? null - } - - setSessionCreator(creator: SessionCreator | null): void { - this.sessionCreator = creator - } - - start(): void { - if (this.started) { - return - } - this.started = true - this.runStartupPass() - } - - stop(): void { - this.started = false - for (const timer of this.timers.values()) { - clearTimeout(timer) - } - this.timers.clear() - } - - list(): ScheduledTasksSettings { - return this.configPresenter.getScheduledTasksConfig() - } - - upsert(input: ScheduledTasksUpsertInput): { - task: ScheduledTask - settings: ScheduledTasksSettings - } { - const now = Date.now() - const current = this.list() - const existingIndex = input.id ? current.tasks.findIndex((task) => task.id === input.id) : -1 - const existing = existingIndex >= 0 ? current.tasks[existingIndex] : null - - const trigger = scheduledTaskTriggerSchema.parse(input.trigger) - const action = scheduledTaskActionSchema.parse(input.action) - - const triggerChanged = !existing || JSON.stringify(existing.trigger) !== JSON.stringify(trigger) - - const task: ScheduledTask = { - id: existing?.id ?? input.id ?? randomUUID(), - name: input.name, - enabled: input.enabled, - trigger, - action, - createdAt: existing?.createdAt ?? now, - // Reset lastFiredAt when the trigger changes so a rescheduled one-shot - // doesn't get skipped on the assumption it has already run. - lastFiredAt: triggerChanged ? null : (existing?.lastFiredAt ?? null) - } - - const tasks = - existingIndex >= 0 - ? current.tasks.map((value, index) => (index === existingIndex ? task : value)) - : [...current.tasks, task] - - const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) - - this.cancel(task.id) - if (task.enabled) { - this.armTask(task, Date.now()) - } - - return { task, settings } - } - - delete(id: string): ScheduledTasksSettings { - const current = this.list() - const next = current.tasks.filter((task) => task.id !== id) - const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks: next }) - this.cancel(id) - return settings - } - - toggle(id: string, enabled: boolean): { task: ScheduledTask; settings: ScheduledTasksSettings } { - const current = this.list() - const existing = current.tasks.find((task) => task.id === id) - if (!existing) { - throw new Error(`Unknown scheduled task: ${id}`) - } - const updated: ScheduledTask = { ...existing, enabled } - const tasks = current.tasks.map((task) => (task.id === id ? updated : task)) - const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) - - this.cancel(id) - if (enabled) { - this.armTask(updated, Date.now()) - } - - return { task: updated, settings } - } - - async fireNow(id: string): Promise<{ task: ScheduledTask; settings: ScheduledTasksSettings }> { - const current = this.list() - const existing = current.tasks.find((task) => task.id === id) - if (!existing) { - throw new Error(`Unknown scheduled task: ${id}`) - } - await this.dispatch(existing) - const settings = this.markFired(existing) - const refreshed = settings.tasks.find((task) => task.id === id) ?? existing - return { task: refreshed, settings } - } - - private runStartupPass(): void { - const now = Date.now() - const settings = this.list() - for (const task of settings.tasks) { - if (!task.enabled) { - continue - } - if (shouldBackfillOneShot(task, now)) { - void this.fireAndPersist(task) - continue - } - this.armTask(task, now) - } - } - - private armTask(task: ScheduledTask, now: number): void { - const nextFireAt = computeNextFireAt(task, now - RECENT_DRIFT_TOLERANCE_MS) - if (!nextFireAt) { - return - } - - const delay = Math.max(0, nextFireAt - now) - if (delay > MAX_TIMEOUT_MS) { - const timer = setTimeout(() => { - this.timers.delete(task.id) - const refreshed = this.list().tasks.find((entry) => entry.id === task.id) - if (refreshed?.enabled) { - this.armTask(refreshed, Date.now()) - } - }, MAX_TIMEOUT_MS) - this.timers.set(task.id, timer) - return - } - - const timer = setTimeout(() => { - this.timers.delete(task.id) - const refreshed = this.list().tasks.find((entry) => entry.id === task.id) - if (!refreshed || !refreshed.enabled) { - return - } - void this.fireAndPersist(refreshed) - }, delay) - this.timers.set(task.id, timer) - } - - private cancel(id: string): void { - const timer = this.timers.get(id) - if (timer) { - clearTimeout(timer) - this.timers.delete(id) - } - } - - private persist(settings: ScheduledTasksSettings): ScheduledTasksSettings { - return this.configPresenter.setScheduledTasksConfig(settings) - } - - private markFired(task: ScheduledTask): ScheduledTasksSettings { - const current = this.list() - const tasks = current.tasks.map((entry) => { - if (entry.id !== task.id) { - return entry - } - // One-shot tasks auto-disable on fire so the user notices and can - // either delete or reschedule. - const disable = entry.trigger.kind === 'once' - return { - ...entry, - lastFiredAt: Date.now(), - enabled: disable ? false : entry.enabled - } - }) - return this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) - } - - private async fireAndPersist(task: ScheduledTask): Promise { - try { - await this.dispatch(task) - } catch (error) { - log.error('[ScheduledTasks] Dispatch failed:', error) - } finally { - this.markFired(task) - if (task.trigger.kind !== 'once') { - // Re-arm for the next recurring slot using the just-persisted state. - const refreshed = this.list().tasks.find((entry) => entry.id === task.id) - if (refreshed?.enabled) { - this.armTask(refreshed, Date.now()) - } - } - } - } - - private async dispatch(task: ScheduledTask): Promise { - await this.runAction(task.id, task.action) - } - - private async runAction(taskId: string, action: ScheduledTaskAction): Promise { - switch (action.kind) { - case 'notify': - await this.notificationPresenter.showNotification({ - id: `scheduled:${taskId}`, - title: action.title, - body: action.body - }) - return - case 'prompt': - if (action.autoSend) { - await this.runPromptAutoSend(taskId, action) - return - } - await this.runPromptDraft(taskId, action) - return - default: { - const _exhaustive: never = action - throw new Error(`[ScheduledTasks] Unhandled action kind: ${String(_exhaustive)}`) - } - } - } - - private async runPromptDraft( - taskId: string, - action: Extract - ): Promise { - const target = this.windowPresenter.mainWindow - if (target && !target.isDestroyed()) { - this.windowPresenter.sendToWindow(target.id, DEEPLINK_EVENTS.START, { - msg: action.message, - modelId: action.modelId ?? null, - systemPrompt: action.systemPrompt ?? '', - mentions: [], - autoSend: false - }) - this.windowPresenter.focusMainWindow() - } else { - log.warn('[ScheduledTasks] No main window available for prompt draft action') - } - - await this.notificationPresenter.showNotification({ - id: `scheduled:${taskId}`, - title: action.title, - body: action.message.slice(0, 200) - }) - } - - private async runPromptAutoSend( - taskId: string, - action: Extract - ): Promise { - if (!this.sessionCreator) { - log.warn('[ScheduledTasks] sessionCreator is not wired; falling back to draft mode') - await this.runPromptDraft(taskId, action) - return - } - - try { - await this.sessionCreator.createSessionForTask({ - agentId: action.agentId ?? SCHEDULED_TASK_DEFAULT_AGENT_ID, - message: action.message, - providerId: action.providerId, - modelId: action.modelId, - systemPrompt: action.systemPrompt - }) - - await this.notificationPresenter.showNotification({ - id: `scheduled:${taskId}`, - title: action.title, - body: action.message.slice(0, 200) - }) - } catch (error) { - log.error('[ScheduledTasks] Failed to create session for task:', error) - // Fall back so the user still sees something happened. - await this.runPromptDraft(taskId, action) - } - } -} - -export { - computeNextFireAt, - normalizeScheduledTasksConfig, - shouldBackfillOneShot -} from './normalize' diff --git a/src/main/presenter/scheduledTasks/normalize.ts b/src/main/presenter/scheduledTasks/normalize.ts deleted file mode 100644 index 19a68100b..000000000 --- a/src/main/presenter/scheduledTasks/normalize.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { randomUUID } from 'node:crypto' -import log from 'electron-log' -import { z } from 'zod' -import { - SCHEDULED_TASKS_VERSION, - type ScheduledTask, - type ScheduledTaskAction, - type ScheduledTaskTrigger, - type ScheduledTasksSettings, - createDefaultScheduledTasksSettings -} from '@shared/scheduledTasks' - -const TriggerSchema = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('once'), firesAt: z.number().int().nonnegative() }), - z.object({ - kind: z.literal('daily'), - hour: z.number().int().min(0).max(23), - minute: z.number().int().min(0).max(59) - }), - z.object({ - kind: z.literal('weekly'), - dayOfWeek: z.number().int().min(0).max(6), - hour: z.number().int().min(0).max(23), - minute: z.number().int().min(0).max(59) - }) -]) - -const ActionSchema = z.discriminatedUnion('kind', [ - z.object({ - kind: z.literal('notify'), - title: z.string().max(200), - body: z.string().max(2000) - }), - z.object({ - kind: z.literal('prompt'), - title: z.string().max(200), - message: z.string().max(20000), - autoSend: z.boolean(), - agentId: z.string().optional(), - providerId: z.string().optional(), - modelId: z.string().optional(), - systemPrompt: z.string().max(20000).optional() - }) -]) - -const ScheduledTaskSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1).max(200), - enabled: z.boolean(), - trigger: TriggerSchema, - action: ActionSchema, - createdAt: z.number().int().nonnegative(), - lastFiredAt: z.number().int().nonnegative().nullable() -}) - -const LooseSchedulerSettingsSchema = z.object({ - version: z.unknown().optional(), - tasks: z.array(z.unknown()).optional() -}) - -const sanitizeTrigger = (input: unknown): ScheduledTaskTrigger | null => { - const parsed = TriggerSchema.safeParse(input) - return parsed.success ? parsed.data : null -} - -const sanitizeAction = (input: unknown): ScheduledTaskAction | null => { - const parsed = ActionSchema.safeParse(input) - return parsed.success ? parsed.data : null -} - -const sanitizeTask = (input: unknown, fallbackIndex: number, now: number): ScheduledTask | null => { - if (!input || typeof input !== 'object') { - return null - } - const record = input as Record - const trigger = sanitizeTrigger(record.trigger) - const action = sanitizeAction(record.action) - if (!trigger || !action) { - return null - } - - const id = - typeof record.id === 'string' && record.id.trim().length > 0 ? record.id.trim() : randomUUID() - const name = - typeof record.name === 'string' && record.name.trim().length > 0 - ? record.name.trim().slice(0, 200) - : `Task ${fallbackIndex + 1}` - const enabled = record.enabled === true - const createdAt = - typeof record.createdAt === 'number' && - Number.isFinite(record.createdAt) && - record.createdAt > 0 - ? record.createdAt - : now - const lastFiredAt = - typeof record.lastFiredAt === 'number' && - Number.isFinite(record.lastFiredAt) && - record.lastFiredAt > 0 - ? record.lastFiredAt - : null - - const candidate = { id, name, enabled, trigger, action, createdAt, lastFiredAt } - const parsed = ScheduledTaskSchema.safeParse(candidate) - return parsed.success ? parsed.data : null -} - -const makeUniqueTaskId = (id: string, seenIds: Set): string => { - if (!seenIds.has(id)) { - return id - } - - let suffix = 2 - let nextId = `${id}-${suffix}` - while (seenIds.has(nextId)) { - suffix += 1 - nextId = `${id}-${suffix}` - } - return nextId -} - -export const normalizeScheduledTasksConfig = ( - input: unknown, - now: number = Date.now() -): ScheduledTasksSettings => { - const defaults = createDefaultScheduledTasksSettings() - const parsed = LooseSchedulerSettingsSchema.safeParse(input) - if (!parsed.success) { - log.warn('[ScheduledTasks] Invalid config, using defaults:', parsed.error?.message) - return defaults - } - - const rawTasks = Array.isArray(parsed.data.tasks) ? parsed.data.tasks : [] - const seenIds = new Set() - const tasks = rawTasks.reduce((acc, candidate, index) => { - const sanitized = sanitizeTask(candidate, index, now) - if (sanitized) { - const id = makeUniqueTaskId(sanitized.id, seenIds) - seenIds.add(id) - acc.push(id === sanitized.id ? sanitized : { ...sanitized, id }) - } else { - log.warn(`[ScheduledTasks] Dropping malformed task at index ${index}`) - } - return acc - }, []) - - return { - version: SCHEDULED_TASKS_VERSION, - tasks - } -} - -const startOfMinute = (timestamp: number): number => { - const date = new Date(timestamp) - date.setSeconds(0, 0) - return date.getTime() -} - -const buildWallClockToday = ( - reference: number, - hour: number, - minute: number, - dayOffset = 0 -): number => { - const date = new Date(reference) - date.setDate(date.getDate() + dayOffset) - date.setHours(hour, minute, 0, 0) - return date.getTime() -} - -/** - * Compute the next absolute timestamp at which `task` should fire, strictly - * after `after`. Returns `null` if the task can no longer fire (one-shot - * already fired or one-shot whose `firesAt` is in the past with respect to - * `after` — backfill handling is up to the caller via `lastFiredAt`). - */ -export const computeNextFireAt = (task: ScheduledTask, after: number): number | null => { - const trigger = task.trigger - switch (trigger.kind) { - case 'once': { - if (task.lastFiredAt) { - return null - } - return trigger.firesAt > after ? trigger.firesAt : null - } - case 'daily': { - let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, 0) - if (candidate <= after) { - candidate = buildWallClockToday(after, trigger.hour, trigger.minute, 1) - } - return candidate - } - case 'weekly': { - const reference = new Date(after) - const currentDay = reference.getDay() - let dayOffset = (trigger.dayOfWeek - currentDay + 7) % 7 - let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset) - if (candidate <= after) { - dayOffset += 7 - candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset) - } - return candidate - } - default: - return null - } -} - -/** - * Returns true when a one-shot task should be backfilled (fired immediately - * on startup) because its `firesAt` is in the past and it has never been - * fired. Recurring tasks are never backfilled. - */ -export const shouldBackfillOneShot = (task: ScheduledTask, now: number): boolean => { - if (task.trigger.kind !== 'once') { - return false - } - if (task.lastFiredAt) { - return false - } - return task.trigger.firesAt <= now -} - -export const startOfMinuteForTests = startOfMinute diff --git a/src/main/presenter/sqlitePresenter/databaseConnection.ts b/src/main/presenter/sqlitePresenter/databaseConnection.ts new file mode 100644 index 000000000..7b4e4c7d1 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/databaseConnection.ts @@ -0,0 +1,18 @@ +import fs from 'fs' +import path from 'path' +import Database from 'better-sqlite3-multiple-ciphers' +import { configureSQLiteConnection } from './connectionConfig' + +function ensureDatabaseDirectory(dbPath: string): void { + const dbDir = path.dirname(dbPath) + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }) + } +} + +export function openSQLiteDatabase(dbPath: string, password?: string): Database.Database { + ensureDatabaseDirectory(dbPath) + const db = new Database(dbPath) + configureSQLiteConnection(db, password) + return db +} diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index d1c859ccf..a47b72400 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -1,6 +1,5 @@ import logger from '@shared/logger' -import Database from 'better-sqlite3-multiple-ciphers' -import path from 'path' +import type Database from 'better-sqlite3-multiple-ciphers' import fs from 'fs' import { ConversationsTable } from './tables/conversations' import { MessagesTable } from './tables/messages' @@ -34,6 +33,7 @@ import { DeepChatPendingInputsTable } from './tables/deepchatPendingInputs' import { DeepChatUsageStatsTable } from './tables/deepchatUsageStats' import { DeepChatTapeEntriesTable } from './tables/deepchatTapeEntries' import { DeepChatTapeSearchProjectionTable } from './tables/deepchatTapeSearchProjection' +import { DeepChatSessionMetadataTable } from './tables/deepchatSessionMetadata' import { LegacyImportStatusTable } from './tables/legacyImportStatus' import { AgentsTable } from './tables/agents' import { AgentMemoryTable } from './tables/agentMemory' @@ -42,13 +42,18 @@ import { ConfigTables } from './tables/configTables' import { NewSessionActiveSkillsTable } from './tables/newSessionActiveSkills' import { NewSessionDisabledAgentToolsTable } from './tables/newSessionDisabledAgentTools' import { SettingsActivityTable } from './tables/settingsActivity' +import { CronJobsTable } from './tables/cronJobs' +import { CronJobRunsTable } from './tables/cronJobRuns' +import { CronJobDeliveriesTable } from './tables/cronJobDeliveries' import type { BaseTable } from './tables/baseTable' import { DatabaseRepairService, SchemaInspector } from './schemaRepair' import type { SchemaTableSpec } from './schemaTypes' import type { SettingsActivityInput, SettingsActivityRecord } from '@shared/contracts/routes' -import { configureSQLiteConnection } from './connectionConfig' +import { openSQLiteDatabase } from './databaseConnection' import { LegacyChatImportService } from '../agentSessionPresenter/legacyImportService' +export { openSQLiteDatabase } from './databaseConnection' + const DESTRUCTIVE_DATABASE_ERROR_PATTERNS = [ /database disk image is malformed/i, /file is not a database/i, @@ -73,20 +78,6 @@ export function isDestructiveDatabaseError(error: unknown): boolean { return DESTRUCTIVE_DATABASE_ERROR_PATTERNS.some((pattern) => pattern.test(message)) } -function ensureDatabaseDirectory(dbPath: string): void { - const dbDir = path.dirname(dbPath) - if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }) - } -} - -export function openSQLiteDatabase(dbPath: string, password?: string): Database.Database { - ensureDatabaseDirectory(dbPath) - const db = new Database(dbPath) - configureSQLiteConnection(db, password) - return db -} - export function repairSQLiteDatabaseFile( dbPath: string, password?: string, @@ -238,6 +229,7 @@ export class SQLitePresenter implements ISQLitePresenter { public deepchatUsageStatsTable!: DeepChatUsageStatsTable public deepchatTapeEntriesTable!: DeepChatTapeEntriesTable public deepchatTapeSearchProjectionTable!: DeepChatTapeSearchProjectionTable + public deepchatSessionMetadataTable!: DeepChatSessionMetadataTable public legacyImportStatusTable!: LegacyImportStatusTable public agentsTable!: AgentsTable public agentMemoryTable!: AgentMemoryTable @@ -246,6 +238,9 @@ export class SQLitePresenter implements ISQLitePresenter { public newSessionActiveSkillsTable!: NewSessionActiveSkillsTable public newSessionDisabledAgentToolsTable!: NewSessionDisabledAgentToolsTable public settingsActivityTable!: SettingsActivityTable + public cronJobsTable!: CronJobsTable + public cronJobRunsTable!: CronJobRunsTable + public cronJobDeliveriesTable!: CronJobDeliveriesTable private currentVersion: number = 0 private dbPath: string private password?: string @@ -426,6 +421,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatUsageStatsTable = new DeepChatUsageStatsTable(this.db) this.deepchatTapeEntriesTable = new DeepChatTapeEntriesTable(this.db) this.deepchatTapeSearchProjectionTable = new DeepChatTapeSearchProjectionTable(this.db) + this.deepchatSessionMetadataTable = new DeepChatSessionMetadataTable(this.db) this.legacyImportStatusTable = new LegacyImportStatusTable(this.db) this.agentsTable = new AgentsTable(this.db) this.agentMemoryTable = new AgentMemoryTable(this.db) @@ -434,6 +430,9 @@ export class SQLitePresenter implements ISQLitePresenter { this.newSessionActiveSkillsTable = new NewSessionActiveSkillsTable(this.db) this.newSessionDisabledAgentToolsTable = new NewSessionDisabledAgentToolsTable(this.db) this.settingsActivityTable = new SettingsActivityTable(this.db) + this.cronJobsTable = new CronJobsTable(this.db) + this.cronJobRunsTable = new CronJobRunsTable(this.db) + this.cronJobDeliveriesTable = new CronJobDeliveriesTable(this.db) // Create only active tables for the new stack. this.acpSessionsTable.createTable() @@ -455,6 +454,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatUsageStatsTable.createTable() this.deepchatTapeEntriesTable.createTable() this.deepchatTapeSearchProjectionTable.createTable() + this.deepchatSessionMetadataTable.createTable() this.legacyImportStatusTable.createTable() this.agentsTable.createTable() this.agentMemoryTable.createTable() @@ -463,6 +463,9 @@ export class SQLitePresenter implements ISQLitePresenter { this.newSessionActiveSkillsTable.createTable() this.newSessionDisabledAgentToolsTable.createTable() this.settingsActivityTable.createTable() + this.cronJobsTable.createTable() + this.cronJobRunsTable.createTable() + this.cronJobDeliveriesTable.createTable() } private initVersionTable() { @@ -500,6 +503,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatUsageStatsTable, this.deepchatTapeEntriesTable, this.deepchatTapeSearchProjectionTable, + this.deepchatSessionMetadataTable, this.legacyImportStatusTable, this.agentsTable, this.agentMemoryTable, @@ -507,7 +511,10 @@ export class SQLitePresenter implements ISQLitePresenter { this.configTables, this.newSessionActiveSkillsTable, this.newSessionDisabledAgentToolsTable, - this.settingsActivityTable + this.settingsActivityTable, + this.cronJobsTable, + this.cronJobRunsTable, + this.cronJobDeliveriesTable ] } @@ -606,6 +613,7 @@ export class SQLitePresenter implements ISQLitePresenter { DELETE FROM deepchat_tape_entries; DELETE FROM deepchat_tape_search_projection; DELETE FROM deepchat_tape_search_projection_meta; + DELETE FROM deepchat_session_metadata; DELETE FROM deepchat_sessions; DELETE FROM new_session_active_skills; DELETE FROM new_session_disabled_agent_tools; diff --git a/src/main/presenter/sqlitePresenter/schemaCatalog.ts b/src/main/presenter/sqlitePresenter/schemaCatalog.ts index 1deaa4ee1..07bf89bc6 100644 --- a/src/main/presenter/sqlitePresenter/schemaCatalog.ts +++ b/src/main/presenter/sqlitePresenter/schemaCatalog.ts @@ -21,6 +21,7 @@ import { DeepChatPendingInputsTable } from './tables/deepchatPendingInputs' import { DeepChatUsageStatsTable } from './tables/deepchatUsageStats' import { DeepChatTapeEntriesTable } from './tables/deepchatTapeEntries' import { DeepChatTapeSearchProjectionTable } from './tables/deepchatTapeSearchProjection' +import { DeepChatSessionMetadataTable } from './tables/deepchatSessionMetadata' import { LegacyImportStatusTable } from './tables/legacyImportStatus' import { AgentsTable } from './tables/agents' import { AgentMemoryTable } from './tables/agentMemory' @@ -28,6 +29,9 @@ import { AgentMemoryAuditTable } from './tables/agentMemoryAudit' import { NewSessionActiveSkillsTable } from './tables/newSessionActiveSkills' import { NewSessionDisabledAgentToolsTable } from './tables/newSessionDisabledAgentTools' import { SettingsActivityTable } from './tables/settingsActivity' +import { CronJobsTable } from './tables/cronJobs' +import { CronJobRunsTable } from './tables/cronJobRuns' +import { CronJobDeliveriesTable } from './tables/cronJobDeliveries' import type { BaseTable } from './tables/baseTable' import type { SchemaTableSpec } from './schemaTypes' import { isSchemaTableCreatedOnFreshInstall } from './schemaCatalogMetadata' @@ -217,6 +221,10 @@ const CATALOG_DEFINITIONS: CatalogDefinition[] = [ name: 'deepchat_tape_search_fts_meta', createTable: (db) => new DeepChatTapeSearchProjectionTable(db) }, + { + name: 'deepchat_session_metadata', + createTable: (db) => new DeepChatSessionMetadataTable(db) + }, { name: 'legacy_import_status', createTable: (db) => new LegacyImportStatusTable(db) @@ -254,6 +262,18 @@ const CATALOG_DEFINITIONS: CatalogDefinition[] = [ { name: 'settings_activity', createTable: (db) => new SettingsActivityTable(db) + }, + { + name: 'cron_jobs', + createTable: (db) => new CronJobsTable(db) + }, + { + name: 'cron_job_runs', + createTable: (db) => new CronJobRunsTable(db) + }, + { + name: 'cron_job_deliveries', + createTable: (db) => new CronJobDeliveriesTable(db) } ] diff --git a/src/main/presenter/sqlitePresenter/tables/cronJobDeliveries.ts b/src/main/presenter/sqlitePresenter/tables/cronJobDeliveries.ts new file mode 100644 index 000000000..4bcc85312 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/cronJobDeliveries.ts @@ -0,0 +1,149 @@ +import { randomUUID } from 'node:crypto' +import Database from 'better-sqlite3-multiple-ciphers' +import type { + CronJobDeliveryStatus, + CronJobDeliveryTarget, + CronJobDeliveryTargetType +} from '@shared/cronJobs' +import { BaseTable } from './baseTable' + +export interface CronJobDeliveryRow { + id: string + job_id: string + run_id: string + target_type: CronJobDeliveryTargetType + target_json: string + status: CronJobDeliveryStatus + remote_message_id: string | null + error: string | null + created_at: number + updated_at: number +} + +export interface CronJobDeliveryInsertInput { + id?: string + jobId: string + runId: string + target: CronJobDeliveryTarget + status: CronJobDeliveryStatus + remoteMessageId?: string | null + error?: string | null + now?: number +} + +const CRON_JOB_DELIVERIES_INDEX_SQL = ` + CREATE INDEX IF NOT EXISTS idx_cron_job_deliveries_run + ON cron_job_deliveries(run_id, created_at); + CREATE INDEX IF NOT EXISTS idx_cron_job_deliveries_remote_message + ON cron_job_deliveries(remote_message_id); +` + +export class CronJobDeliveriesTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'cron_job_deliveries') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS cron_job_deliveries ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + run_id TEXT NOT NULL, + target_type TEXT NOT NULL CHECK(target_type IN ('remote')), + target_json TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('success', 'failed')), + remote_message_id TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + ${CRON_JOB_DELIVERIES_INDEX_SQL} + ` + } + + override createTable(): void { + super.createTable() + this.db.exec(CRON_JOB_DELIVERIES_INDEX_SQL) + } + + getMigrationSQL(): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + insert(input: CronJobDeliveryInsertInput): CronJobDeliveryRow { + const now = input.now ?? Date.now() + const id = input.id ?? randomUUID() + + this.db + .prepare( + `INSERT INTO cron_job_deliveries ( + id, + job_id, + run_id, + target_type, + target_json, + status, + remote_message_id, + error, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + id, + input.jobId, + input.runId, + input.target.type, + JSON.stringify(input.target), + input.status, + input.remoteMessageId ?? null, + input.error ?? null, + now, + now + ) + + return this.requireDelivery(id) + } + + listByRun(runId: string): CronJobDeliveryRow[] { + return this.db + .prepare( + `SELECT * + FROM cron_job_deliveries + WHERE run_id = ? + ORDER BY created_at ASC, id ASC` + ) + .all(runId) as CronJobDeliveryRow[] + } + + findByRemoteMessageId(remoteMessageId: string): CronJobDeliveryRow | undefined { + return this.db + .prepare( + `SELECT * + FROM cron_job_deliveries + WHERE remote_message_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 1` + ) + .get(remoteMessageId) as CronJobDeliveryRow | undefined + } + + deleteByJob(jobId: string): number { + return this.db.prepare('DELETE FROM cron_job_deliveries WHERE job_id = ?').run(jobId).changes + } + + private requireDelivery(id: string): CronJobDeliveryRow { + const row = this.db.prepare('SELECT * FROM cron_job_deliveries WHERE id = ?').get(id) as + | CronJobDeliveryRow + | undefined + if (!row) { + throw new Error(`Failed to reload cron job delivery: ${id}`) + } + return row + } +} diff --git a/src/main/presenter/sqlitePresenter/tables/cronJobRuns.ts b/src/main/presenter/sqlitePresenter/tables/cronJobRuns.ts new file mode 100644 index 000000000..90ecc1dd4 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/cronJobRuns.ts @@ -0,0 +1,319 @@ +import { randomUUID } from 'node:crypto' +import Database from 'better-sqlite3-multiple-ciphers' +import type { CronJobRunReason, CronJobRunStatus } from '@shared/cronJobs' +import { BaseTable } from './baseTable' + +export interface CronJobRunRow { + id: string + job_id: string + session_id: string | null + scheduled_at: number + queued_at: number + started_at: number | null + completed_at: number | null + status: CronJobRunStatus + reason: CronJobRunReason + output_message_id: string | null + output_preview: string | null + error: string | null + claimed_at: number | null + claim_owner: string | null + created_at: number + updated_at: number +} + +export interface CronJobRunInsertInput { + id?: string + jobId: string + scheduledAt: number + queuedAt?: number + reason: CronJobRunReason + now?: number +} + +const CRON_JOB_RUNS_INDEX_SQL = ` + CREATE INDEX IF NOT EXISTS idx_cron_job_runs_job_created + ON cron_job_runs(job_id, created_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_cron_job_runs_status_queued + ON cron_job_runs(status, queued_at ASC, id ASC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_cron_job_runs_scheduled_dedupe + ON cron_job_runs(job_id, scheduled_at) + WHERE reason = 'scheduled'; +` + +export class CronJobRunsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'cron_job_runs') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS cron_job_runs ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + session_id TEXT, + scheduled_at INTEGER NOT NULL, + queued_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + status TEXT NOT NULL CHECK(status IN ('queued', 'running', 'completed', 'failed', 'cancelled')), + reason TEXT NOT NULL CHECK(reason IN ('scheduled', 'manual')), + output_message_id TEXT, + output_preview TEXT, + error TEXT, + claimed_at INTEGER, + claim_owner TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + ${CRON_JOB_RUNS_INDEX_SQL} + ` + } + + override createTable(): void { + super.createTable() + this.db.exec(CRON_JOB_RUNS_INDEX_SQL) + } + + getMigrationSQL(): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + get(id: string): CronJobRunRow | undefined { + return this.db.prepare('SELECT * FROM cron_job_runs WHERE id = ?').get(id) as + | CronJobRunRow + | undefined + } + + insertQueued(input: CronJobRunInsertInput): CronJobRunRow { + const now = input.now ?? Date.now() + const id = input.id ?? randomUUID() + const queuedAt = input.queuedAt ?? now + + this.db + .prepare( + `INSERT INTO cron_job_runs ( + id, + job_id, + session_id, + scheduled_at, + queued_at, + started_at, + completed_at, + status, + reason, + output_message_id, + output_preview, + error, + claimed_at, + claim_owner, + created_at, + updated_at + ) + VALUES (?, ?, NULL, ?, ?, NULL, NULL, 'queued', ?, NULL, NULL, NULL, NULL, NULL, ?, ?)` + ) + .run(id, input.jobId, input.scheduledAt, queuedAt, input.reason, now, now) + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to queue cron job run: ${id}`) + } + return row + } + + claimQueued(id: string, claimOwner: string, startedAt = Date.now()): CronJobRunRow | null { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'running', + started_at = ?, + claimed_at = ?, + claim_owner = ?, + updated_at = ? + WHERE id = ? + AND status = 'queued'` + ) + .run(startedAt, startedAt, claimOwner, startedAt, id) + return result.changes === 0 ? null : this.requireRun(id) + } + + markRunning(id: string, startedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'running', + started_at = COALESCE(started_at, ?), + updated_at = ? + WHERE id = ?` + ) + .run(startedAt, startedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + markCompleted(id: string, completedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'completed', + completed_at = ?, + error = NULL, + updated_at = ? + WHERE id = ?` + ) + .run(completedAt, completedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + updateSession(id: string, sessionId: string, updatedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET session_id = ?, + updated_at = ? + WHERE id = ?` + ) + .run(sessionId, updatedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + updateOutput( + id: string, + input: { + outputMessageId?: string | null + outputPreview?: string | null + updatedAt?: number + } + ): CronJobRunRow { + const updatedAt = input.updatedAt ?? Date.now() + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET output_message_id = COALESCE(?, output_message_id), + output_preview = COALESCE(?, output_preview), + updated_at = ? + WHERE id = ?` + ) + .run(input.outputMessageId ?? null, input.outputPreview ?? null, updatedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + markFailed(id: string, error: string, completedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'failed', + completed_at = ?, + error = ?, + updated_at = ? + WHERE id = ?` + ) + .run(completedAt, error, completedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + markRunningFailed(error: string, completedAt = Date.now()): number { + return this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'failed', + completed_at = ?, + error = ?, + updated_at = ? + WHERE status = 'running'` + ) + .run(completedAt, error, completedAt).changes + } + + markCancelled(id: string, error: string | null = null, completedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'cancelled', + completed_at = ?, + error = ?, + updated_at = ? + WHERE id = ?` + ) + .run(completedAt, error, completedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job run: ${id}`) + } + return this.requireRun(id) + } + + releaseQueued(id: string, updatedAt = Date.now()): CronJobRunRow { + const result = this.db + .prepare( + `UPDATE cron_job_runs + SET status = 'queued', + started_at = NULL, + claimed_at = NULL, + claim_owner = NULL, + updated_at = ? + WHERE id = ? + AND status = 'running'` + ) + .run(updatedAt, id) + if (result.changes === 0) { + throw new Error(`Unknown running cron job run: ${id}`) + } + return this.requireRun(id) + } + + countActiveByJob(jobId: string, excludeRunId?: string): number { + const row = this.db + .prepare( + `SELECT COUNT(*) AS count + FROM cron_job_runs + WHERE job_id = ? + AND status = 'running' + AND (? IS NULL OR id != ?)` + ) + .get(jobId, excludeRunId ?? null, excludeRunId ?? null) as { count: number } + return row.count + } + + listByJob(jobId: string, limit = 50): CronJobRunRow[] { + const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200) + return this.db + .prepare( + `SELECT * + FROM cron_job_runs + WHERE job_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ?` + ) + .all(jobId, safeLimit) as CronJobRunRow[] + } + + deleteByJob(jobId: string): number { + return this.db.prepare('DELETE FROM cron_job_runs WHERE job_id = ?').run(jobId).changes + } + + private requireRun(id: string): CronJobRunRow { + const row = this.get(id) + if (!row) { + throw new Error(`Failed to reload cron job run: ${id}`) + } + return row + } +} diff --git a/src/main/presenter/sqlitePresenter/tables/cronJobs.ts b/src/main/presenter/sqlitePresenter/tables/cronJobs.ts new file mode 100644 index 000000000..4dee3ef58 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/cronJobs.ts @@ -0,0 +1,361 @@ +import { randomUUID } from 'node:crypto' +import Database from 'better-sqlite3-multiple-ciphers' +import { + CRON_JOBS_DEFAULT_DELIVERY, + CRON_JOBS_DEFAULT_MISFIRE_POLICY, + CRON_JOBS_DEFAULT_RUNTIME, + type CronJobAgentSnapshot, + type CronJobDelivery, + type CronJobModelPolicy, + type CronJobMisfirePolicy, + type CronJobOutputMode, + type CronJobRuntimePolicy, + type CronJobRuntimeSettings, + type CronJobStatus +} from '@shared/cronJobs' +import { BaseTable } from './baseTable' + +export interface CronJobRow { + id: string + name: string + description: string | null + enabled: number + status: CronJobStatus + cron_expr: string + timezone: string + agent_id: string | null + next_run_at: number | null + misfire_policy: CronJobMisfirePolicy + max_catch_up_runs: number | null + schedule_error: string | null + task_prompt: string + task_system_instruction: string | null + task_output_mode: CronJobOutputMode + model_policy: CronJobModelPolicy + tool_policy: CronJobRuntimePolicy + permission_policy: CronJobRuntimePolicy + runtime_json: string + agent_snapshot_json: string | null + delivery_json: string + created_at: number + updated_at: number +} + +export interface CronJobTableUpsertInput { + id?: string + name: string + description?: string | null + enabled: boolean + status?: CronJobStatus + cronExpr: string + timezone: string + agentId?: string | null + nextRunAt?: number | null + misfirePolicy?: CronJobMisfirePolicy + maxCatchUpRuns?: number | null + scheduleError?: string | null + taskPrompt?: string + taskSystemInstruction?: string | null + taskOutputMode?: CronJobOutputMode + modelPolicy?: CronJobModelPolicy + toolPolicy?: CronJobRuntimePolicy + permissionPolicy?: CronJobRuntimePolicy + runtime?: CronJobRuntimeSettings + agentSnapshot?: CronJobAgentSnapshot | null + delivery?: CronJobDelivery + now?: number +} + +const CRON_JOBS_SCHEMA_VERSION = 40 + +const CRON_JOBS_INDEX_SQL = ` + CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled_next_run + ON cron_jobs(enabled, next_run_at); + CREATE INDEX IF NOT EXISTS idx_cron_jobs_updated_at + ON cron_jobs(updated_at DESC, id DESC); +` + +export class CronJobsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'cron_jobs') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS cron_jobs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0, 1)), + status TEXT NOT NULL DEFAULT 'disabled' CHECK(status IN ('ready', 'disabled', 'invalid_agent')), + cron_expr TEXT NOT NULL, + timezone TEXT NOT NULL, + agent_id TEXT, + next_run_at INTEGER, + misfire_policy TEXT NOT NULL DEFAULT 'skip' CHECK(misfire_policy IN ('skip', 'run_once')), + max_catch_up_runs INTEGER, + schedule_error TEXT, + task_prompt TEXT NOT NULL DEFAULT '', + task_system_instruction TEXT, + task_output_mode TEXT NOT NULL DEFAULT 'final_message' CHECK(task_output_mode IN ('final_message', 'structured_json', 'artifact')), + model_policy TEXT NOT NULL DEFAULT 'follow_agent' CHECK(model_policy IN ('follow_agent', 'pin_current')), + tool_policy TEXT NOT NULL DEFAULT 'follow_agent' CHECK(tool_policy IN ('follow_agent', 'snapshot')), + permission_policy TEXT NOT NULL DEFAULT 'follow_agent' CHECK(permission_policy IN ('follow_agent', 'snapshot')), + runtime_json TEXT NOT NULL DEFAULT '{}', + agent_snapshot_json TEXT, + delivery_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + ${CRON_JOBS_INDEX_SQL} + ` + } + + override createTable(): void { + super.createTable() + this.db.exec(CRON_JOBS_INDEX_SQL) + } + + getMigrationSQL(_version: number): string | null { + return null + } + + getLatestVersion(): number { + return CRON_JOBS_SCHEMA_VERSION + } + + list(): CronJobRow[] { + return this.db + .prepare( + `SELECT * + FROM cron_jobs + ORDER BY updated_at DESC, id DESC` + ) + .all() as CronJobRow[] + } + + get(id: string): CronJobRow | undefined { + return this.db.prepare('SELECT * FROM cron_jobs WHERE id = ?').get(id) as CronJobRow | undefined + } + + upsert(input: CronJobTableUpsertInput): CronJobRow { + const now = input.now ?? Date.now() + const existing = input.id ? this.get(input.id) : undefined + const id = existing?.id ?? input.id ?? randomUUID() + const createdAt = existing?.created_at ?? now + const description = + input.description === undefined ? (existing?.description ?? null) : input.description + const status = input.status ?? existing?.status ?? (input.enabled ? 'ready' : 'disabled') + const nextRunAt = + input.nextRunAt === undefined ? (existing?.next_run_at ?? null) : input.nextRunAt + const misfirePolicy = + input.misfirePolicy ?? existing?.misfire_policy ?? CRON_JOBS_DEFAULT_MISFIRE_POLICY + const maxCatchUpRuns = + input.maxCatchUpRuns === undefined + ? (existing?.max_catch_up_runs ?? null) + : input.maxCatchUpRuns + const scheduleError = + input.scheduleError === undefined ? (existing?.schedule_error ?? null) : input.scheduleError + const taskPrompt = + input.taskPrompt === undefined ? (existing?.task_prompt ?? '') : input.taskPrompt + const taskSystemInstruction = + input.taskSystemInstruction === undefined + ? (existing?.task_system_instruction ?? null) + : input.taskSystemInstruction + const taskOutputMode = + input.taskOutputMode ?? existing?.task_output_mode ?? ('final_message' as const) + const modelPolicy = input.modelPolicy ?? existing?.model_policy ?? ('follow_agent' as const) + const toolPolicy = input.toolPolicy ?? existing?.tool_policy ?? ('follow_agent' as const) + const permissionPolicy = + input.permissionPolicy ?? existing?.permission_policy ?? ('follow_agent' as const) + const runtimeJson = + input.runtime === undefined + ? (existing?.runtime_json ?? '{}') + : JSON.stringify(input.runtime ?? CRON_JOBS_DEFAULT_RUNTIME) + const agentSnapshotJson = + input.agentSnapshot === undefined + ? (existing?.agent_snapshot_json ?? null) + : input.agentSnapshot + ? JSON.stringify(input.agentSnapshot) + : null + const deliveryJson = + input.delivery === undefined + ? (existing?.delivery_json ?? '{}') + : JSON.stringify(input.delivery ?? CRON_JOBS_DEFAULT_DELIVERY) + + this.db + .prepare( + `INSERT INTO cron_jobs ( + id, + name, + description, + enabled, + status, + cron_expr, + timezone, + agent_id, + next_run_at, + misfire_policy, + max_catch_up_runs, + schedule_error, + task_prompt, + task_system_instruction, + task_output_mode, + model_policy, + tool_policy, + permission_policy, + runtime_json, + agent_snapshot_json, + delivery_json, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + enabled = excluded.enabled, + status = excluded.status, + cron_expr = excluded.cron_expr, + timezone = excluded.timezone, + agent_id = excluded.agent_id, + next_run_at = excluded.next_run_at, + misfire_policy = excluded.misfire_policy, + max_catch_up_runs = excluded.max_catch_up_runs, + schedule_error = excluded.schedule_error, + task_prompt = excluded.task_prompt, + task_system_instruction = excluded.task_system_instruction, + task_output_mode = excluded.task_output_mode, + model_policy = excluded.model_policy, + tool_policy = excluded.tool_policy, + permission_policy = excluded.permission_policy, + runtime_json = excluded.runtime_json, + agent_snapshot_json = excluded.agent_snapshot_json, + delivery_json = excluded.delivery_json, + updated_at = excluded.updated_at` + ) + .run( + id, + input.name, + description, + input.enabled ? 1 : 0, + status, + input.cronExpr, + input.timezone, + input.agentId ?? null, + nextRunAt, + misfirePolicy, + maxCatchUpRuns, + scheduleError, + taskPrompt, + taskSystemInstruction, + taskOutputMode, + modelPolicy, + toolPolicy, + permissionPolicy, + runtimeJson, + agentSnapshotJson, + deliveryJson, + createdAt, + now + ) + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to persist cron job: ${id}`) + } + return row + } + + delete(id: string): number { + return this.db.prepare('DELETE FROM cron_jobs WHERE id = ?').run(id).changes + } + + setEnabled(id: string, enabled: boolean, now = Date.now()): CronJobRow { + const result = this.db + .prepare('UPDATE cron_jobs SET enabled = ?, updated_at = ? WHERE id = ?') + .run(enabled ? 1 : 0, now, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job: ${id}`) + } + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to reload cron job: ${id}`) + } + return row + } + + updateNextRunAt(id: string, nextRunAt: number | null, now = Date.now()): CronJobRow { + const result = this.db + .prepare('UPDATE cron_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?') + .run(nextRunAt, now, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job: ${id}`) + } + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to reload cron job: ${id}`) + } + return row + } + + updateScheduleState( + id: string, + input: { + nextRunAt: number | null + scheduleError: string | null + now?: number + } + ): CronJobRow { + const now = input.now ?? Date.now() + const result = this.db + .prepare( + `UPDATE cron_jobs + SET next_run_at = ?, + schedule_error = ?, + updated_at = ? + WHERE id = ?` + ) + .run(input.nextRunAt, input.scheduleError, now, id) + if (result.changes === 0) { + throw new Error(`Unknown cron job: ${id}`) + } + + const row = this.get(id) + if (!row) { + throw new Error(`Failed to reload cron job: ${id}`) + } + return row + } + + countEnabled(): number { + const row = this.db + .prepare( + `SELECT COUNT(*) AS count + FROM cron_jobs + WHERE enabled = 1 + AND status = 'ready' + AND agent_id IS NOT NULL + AND task_prompt != ''` + ) + .get() as { count: number } | undefined + return row?.count ?? 0 + } + + getNextEnabledRunAt(): number | null { + const row = this.db + .prepare( + `SELECT MIN(next_run_at) AS next_run_at + FROM cron_jobs + WHERE enabled = 1 + AND status = 'ready' + AND agent_id IS NOT NULL + AND task_prompt != '' + AND next_run_at IS NOT NULL` + ) + .get() as { next_run_at: number | null } | undefined + return row?.next_run_at ?? null + } +} diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatSessionMetadata.ts b/src/main/presenter/sqlitePresenter/tables/deepchatSessionMetadata.ts new file mode 100644 index 000000000..62f2a52c7 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/deepchatSessionMetadata.ts @@ -0,0 +1,93 @@ +import Database from 'better-sqlite3-multiple-ciphers' +import type { SessionMetadata } from '@shared/types/agent-interface' +import { BaseTable } from './baseTable' + +export interface DeepChatSessionMetadataRow { + session_id: string + source: string + metadata_json: string + created_at: number + updated_at: number +} + +export class DeepChatSessionMetadataTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'deepchat_session_metadata') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS deepchat_session_metadata ( + session_id TEXT PRIMARY KEY, + source TEXT NOT NULL, + metadata_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_deepchat_session_metadata_source + ON deepchat_session_metadata(source, updated_at DESC); + ` + } + + getMigrationSQL(): string | null { + return null + } + + getLatestVersion(): number { + return 0 + } + + upsert(sessionId: string, metadata: SessionMetadata, now = Date.now()): void { + this.db + .prepare( + `INSERT INTO deepchat_session_metadata ( + session_id, + source, + metadata_json, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + source = excluded.source, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at` + ) + .run(sessionId, metadata.source, JSON.stringify(metadata), now, now) + } + + get(sessionId: string): SessionMetadata | null { + const row = this.db + .prepare('SELECT * FROM deepchat_session_metadata WHERE session_id = ?') + .get(sessionId) as DeepChatSessionMetadataRow | undefined + return row ? this.parseMetadata(row) : null + } + + delete(sessionId: string): void { + this.db.prepare('DELETE FROM deepchat_session_metadata WHERE session_id = ?').run(sessionId) + } + + private parseMetadata(row: DeepChatSessionMetadataRow): SessionMetadata | null { + try { + const parsed = JSON.parse(row.metadata_json) as Partial + if ( + row.source === 'cron_job' && + parsed.source === 'cron_job' && + typeof parsed.cronJobId === 'string' && + typeof parsed.cronJobRunId === 'string' && + typeof parsed.scheduledAt === 'number' + ) { + return { + source: 'cron_job', + cronJobId: parsed.cronJobId, + cronJobRunId: parsed.cronJobRunId, + scheduledAt: parsed.scheduledAt + } + } + } catch { + return null + } + + return null + } +} diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index dba9ea27b..0d4c9fa97 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -41,6 +41,12 @@ import { AgentPlanTool, UPDATE_PLAN_TOOL_NAME } from './agentPlanTool' import { AgentTapeToolHandler } from './agentTapeTools' import { AgentMemoryToolHandler } from './agentMemoryTools' import { createAgentToolErrorResult } from '@shared/lib/agentToolResultEnvelope' +import { CRON_JOB_AGENT_TOOL_NAME } from '@shared/agentTools' +import { + CRON_JOB_TOOL_SERVER_NAME, + CronJobToolHandler, + cronJobActionNeedsPermission +} from './cronJobTool' import { isYoBrowserUnavailableError } from '../../browser/YoBrowserErrors' // Consider moving to a shared handlers location in future refactoring @@ -140,6 +146,7 @@ export class AgentToolManager { private planTool: AgentPlanTool | null = null private tapeToolHandler: AgentTapeToolHandler | null = null private memoryToolHandler: AgentMemoryToolHandler | null = null + private cronJobToolHandler: CronJobToolHandler | null = null private readonly fffSearchService = new FffSearchService() private static readonly READ_FILE_AUTO_TRUNCATE_THRESHOLD = 4500 @@ -310,6 +317,7 @@ export class AgentToolManager { this.planTool = new AgentPlanTool() this.tapeToolHandler = new AgentTapeToolHandler(this.runtimePort) this.memoryToolHandler = new AgentMemoryToolHandler(this.runtimePort) + this.cronJobToolHandler = new CronJobToolHandler(this.runtimePort) if (this.agentWorkspacePath) { this.fileSystemHandler = new AgentFileSystemHandler([this.agentWorkspacePath]) this.bashHandler = new AgentBashHandler( @@ -411,6 +419,11 @@ export class AgentToolManager { } } + // 2.3. Scheduled task tool (disabled by default in DeepChat agent settings) + if (isAgentMode && this.cronJobToolHandler?.canUse()) { + defs.push(this.cronJobToolHandler.getToolDefinition()) + } + // 2.5. Subagent orchestration tool (deepchat regular sessions only) if (isAgentMode && context.conversationId && this.subagentOrchestratorTool) { try { @@ -541,6 +554,10 @@ export class AgentToolManager { return await this.memoryToolHandler.call(toolName, args, conversationId) } + if (this.cronJobToolHandler?.isCronJobTool(toolName)) { + return await this.cronJobToolHandler.call(args) + } + // Route to process tool if (this.isProcessTool(toolName)) { return await this.callProcessTool(toolName, args, conversationId) @@ -1973,6 +1990,17 @@ export class AgentToolManager { const readTools = ['read', GLOB_TOOL_NAME, GREP_TOOL_NAME] const allowExternalFileAccess = options.allowExternalFileAccess === true + if (toolName === CRON_JOB_AGENT_TOOL_NAME && cronJobActionNeedsPermission(args)) { + return { + needsPermission: true, + toolName, + serverName: CRON_JOB_TOOL_SERVER_NAME, + permissionType: 'write', + description: 'Scheduled task changes require approval.', + conversationId + } + } + if (this.isFileSystemTool(toolName)) { if (!this.fileSystemHandler) { throw new Error('FileSystem handler not initialized') diff --git a/src/main/presenter/toolPresenter/agentTools/cronJobTool.ts b/src/main/presenter/toolPresenter/agentTools/cronJobTool.ts new file mode 100644 index 000000000..d5b709d4c --- /dev/null +++ b/src/main/presenter/toolPresenter/agentTools/cronJobTool.ts @@ -0,0 +1,355 @@ +import { z } from 'zod' +import { toDeepChatJsonSchema } from '@shared/lib/zodJsonSchema' +import type { MCPToolDefinition } from '@shared/presenter' +import { CRON_JOB_AGENT_TOOL_NAME } from '@shared/agentTools' +import { + CRON_JOBS_DEFAULT_CRON_EXPR, + CRON_JOBS_DEFAULT_DELIVERY, + CRON_JOBS_DEFAULT_MISFIRE_POLICY, + CRON_JOBS_DEFAULT_RUNTIME, + CRON_JOBS_DEFAULT_TIMEZONE, + type CronJob, + type CronJobDelivery, + type CronJobRuntimeSettings +} from '@shared/cronJobs' +import { createAgentToolSuccessResult } from '@shared/lib/agentToolResultEnvelope' +import type { AgentToolRuntimePort, AgentToolCronJobUpsertInput } from '../runtimePorts' +import type { AgentToolCallResult } from './agentToolManager' + +export const CRON_JOB_TOOL_SERVER_NAME = 'scheduled' + +const WRITE_ACTIONS = new Set(['create', 'update', 'delete', 'pause', 'resume', 'run_now']) +const MODEL_TEXT_PREVIEW_LIMIT = 500 + +const runtimePatchSchema = z.strictObject({ + maxDurationMs: z.number().int().positive().optional(), + maxTurns: z.number().int().positive().optional(), + concurrencyPolicy: z.enum(['skip', 'queue']).optional() +}) + +const deliveryTargetSchema = z.strictObject({ + type: z.literal('remote'), + remoteId: z.string().trim().min(1), + channelId: z.string().trim().min(1), + mode: z.enum(['summary', 'full']).optional() +}) + +const deliverySchema = z.strictObject({ + targets: z.array(deliveryTargetSchema).optional(), + suppressSuccessNotification: z.boolean().optional(), + notifyOnFailure: z.boolean().optional() +}) + +const createJobSchema = z.strictObject({ + name: z.string().trim().min(1).max(200), + description: z.string().trim().optional(), + cronExpr: z.string().trim().min(1).optional(), + timezone: z.string().trim().min(1).optional(), + agentId: z.string().trim().min(1), + taskPrompt: z.string().trim().min(1), + taskSystemInstruction: z.string().trim().optional(), + enabled: z.boolean().optional(), + runtime: runtimePatchSchema.optional(), + delivery: deliverySchema.optional() +}) + +const updatePatchSchema = createJobSchema + .omit({ agentId: true, taskPrompt: true }) + .extend({ + agentId: z.string().trim().min(1).nullable().optional(), + taskPrompt: z.string().trim().min(1).optional() + }) + .partial() + +const cronJobToolSchema = z.strictObject({ + action: z.enum([ + 'create', + 'list', + 'show', + 'update', + 'pause', + 'resume', + 'delete', + 'run_now', + 'history', + 'preview_schedule' + ]), + jobId: z.string().trim().min(1).optional(), + job: createJobSchema.optional(), + patch: updatePatchSchema.optional(), + limit: z.number().int().min(1).max(20).optional(), + cronExpr: z.string().trim().min(1).optional(), + timezone: z.string().trim().min(1).optional(), + count: z.number().int().min(1).max(20).optional() +}) + +type CronJobToolInput = z.infer +type CronJobDeliveryInput = z.infer | Partial + +const requirePort = (port: T | undefined, name: string): T => { + if (!port) { + throw new Error(`Cron job tool is unavailable: missing ${name}.`) + } + return port +} + +const requireJobId = (input: CronJobToolInput): string => { + if (!input.jobId) { + throw new Error('jobId is required for this cronjob action.') + } + return input.jobId +} + +const normalizeRuntime = (runtime?: Partial): CronJobRuntimeSettings => ({ + ...CRON_JOBS_DEFAULT_RUNTIME, + ...runtime +}) + +const normalizeDelivery = (delivery?: CronJobDeliveryInput): CronJobDelivery => ({ + targets: delivery?.targets?.map((target) => ({ + ...target, + mode: target.mode ?? 'summary' + })) ?? [...CRON_JOBS_DEFAULT_DELIVERY.targets], + suppressSuccessNotification: + delivery?.suppressSuccessNotification ?? CRON_JOBS_DEFAULT_DELIVERY.suppressSuccessNotification, + notifyOnFailure: delivery?.notifyOnFailure ?? CRON_JOBS_DEFAULT_DELIVERY.notifyOnFailure +}) + +const toCreateInput = (input: CronJobToolInput): AgentToolCronJobUpsertInput => { + const job = input.job + if (!job) { + throw new Error('job is required for create.') + } + return { + name: job.name, + description: job.description ?? null, + enabled: job.enabled ?? false, + cronExpr: job.cronExpr ?? CRON_JOBS_DEFAULT_CRON_EXPR, + timezone: job.timezone ?? CRON_JOBS_DEFAULT_TIMEZONE, + agentId: job.agentId, + misfirePolicy: CRON_JOBS_DEFAULT_MISFIRE_POLICY, + maxCatchUpRuns: null, + taskPrompt: job.taskPrompt, + taskSystemInstruction: job.taskSystemInstruction ?? null, + taskOutputMode: 'final_message', + modelPolicy: 'follow_agent', + toolPolicy: 'follow_agent', + permissionPolicy: 'follow_agent', + runtime: normalizeRuntime(job.runtime), + delivery: normalizeDelivery(job.delivery) + } +} + +const toUpdateInput = ( + existing: CronJob, + patch: CronJobToolInput['patch'] +): AgentToolCronJobUpsertInput => { + if (!patch) { + throw new Error('patch is required for update.') + } + return { + id: existing.id, + name: patch.name ?? existing.name, + description: patch.description ?? existing.description, + enabled: patch.enabled ?? existing.enabled, + cronExpr: patch.cronExpr ?? existing.cronExpr, + timezone: patch.timezone ?? existing.timezone, + agentId: patch.agentId === undefined ? existing.agentId : patch.agentId, + misfirePolicy: existing.misfirePolicy, + maxCatchUpRuns: existing.maxCatchUpRuns, + taskPrompt: patch.taskPrompt ?? existing.taskPrompt, + taskSystemInstruction: + patch.taskSystemInstruction === undefined + ? existing.taskSystemInstruction + : patch.taskSystemInstruction, + taskOutputMode: existing.taskOutputMode, + modelPolicy: existing.modelPolicy, + toolPolicy: existing.toolPolicy, + permissionPolicy: existing.permissionPolicy, + runtime: normalizeRuntime({ ...existing.runtime, ...patch.runtime }), + delivery: normalizeDelivery({ ...existing.delivery, ...patch.delivery }) + } +} + +const summarizeJobs = (jobs: CronJob[]): string => + jobs.length === 0 + ? 'No scheduled tasks.' + : `${jobs.length} scheduled task${jobs.length === 1 ? '' : 's'}: ${jobs + .map((job) => job.name) + .join(', ')}.` + +const previewText = (value: string | null | undefined): string | null => { + if (!value) { + return null + } + return value.length > MODEL_TEXT_PREVIEW_LIMIT + ? `${value.slice(0, MODEL_TEXT_PREVIEW_LIMIT).trimEnd()}...` + : value +} + +const toModelJob = (job: CronJob) => ({ + id: job.id, + name: job.name, + description: job.description, + enabled: job.enabled, + status: job.status, + cronExpr: job.cronExpr, + timezone: job.timezone, + agentId: job.agentId, + nextRunAt: job.nextRunAt, + scheduleError: job.scheduleError, + runtime: job.runtime, + delivery: { + targetCount: job.delivery.targets.length, + suppressSuccessNotification: job.delivery.suppressSuccessNotification, + notifyOnFailure: job.delivery.notifyOnFailure + }, + taskPromptPreview: previewText(job.taskPrompt), + taskSystemInstructionPreview: previewText(job.taskSystemInstruction) +}) + +const createResult = ( + payload: unknown, + summary: string, + isError = false, + modelPayload = payload +): AgentToolCallResult => { + const content = JSON.stringify(modelPayload, null, 2) + return { + content, + rawData: { + content, + isError, + toolResult: createAgentToolSuccessResult(CRON_JOB_AGENT_TOOL_NAME, modelPayload, { + summary, + data: modelPayload + }) + } + } +} + +export const cronJobActionNeedsPermission = (args: Record): boolean => + typeof args.action === 'string' && WRITE_ACTIONS.has(args.action) + +export class CronJobToolHandler { + constructor(private readonly runtimePort: AgentToolRuntimePort) {} + + isCronJobTool(toolName: string): boolean { + return toolName === CRON_JOB_AGENT_TOOL_NAME + } + + canUse(): boolean { + return Boolean(this.runtimePort.listCronJobs && this.runtimePort.previewCronSchedule) + } + + getToolDefinition(): MCPToolDefinition { + return { + type: 'function', + function: { + name: CRON_JOB_AGENT_TOOL_NAME, + description: + 'Manage Scheduled tasks. Read actions list, show, history, and preview schedules. Write actions create, update, pause, resume, delete, or run a task after user approval.', + parameters: toDeepChatJsonSchema(cronJobToolSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: CRON_JOB_TOOL_SERVER_NAME, + icons: '⏱️', + description: 'DeepChat Scheduled tasks' + } + } + } + + async call(args: Record): Promise { + const input = cronJobToolSchema.parse(args) + const listCronJobs = requirePort(this.runtimePort.listCronJobs, 'listCronJobs') + + if (input.action === 'list') { + const result = await listCronJobs() + return createResult(result, summarizeJobs(result.jobs), false, { + ...result, + jobs: result.jobs.map(toModelJob) + }) + } + + if (input.action === 'show') { + const jobId = requireJobId(input) + const result = await listCronJobs() + const job = result.jobs.find((item) => item.id === jobId) + if (!job) { + throw new Error(`Cron job not found: ${jobId}`) + } + return createResult({ job }, `Scheduled task "${job.name}".`, false, { + job: toModelJob(job) + }) + } + + if (input.action === 'preview_schedule') { + const previewCronSchedule = requirePort( + this.runtimePort.previewCronSchedule, + 'previewCronSchedule' + ) + const cronExpr = input.cronExpr ?? CRON_JOBS_DEFAULT_CRON_EXPR + const timezone = input.timezone ?? CRON_JOBS_DEFAULT_TIMEZONE + const result = await previewCronSchedule({ + cronExpr, + timezone, + count: input.count + }) + return createResult(result, `Previewed schedule ${cronExpr} in ${timezone}.`) + } + + if (input.action === 'history') { + const listCronJobRuns = requirePort(this.runtimePort.listCronJobRuns, 'listCronJobRuns') + const jobId = requireJobId(input) + const runs = await listCronJobRuns(jobId, input.limit) + return createResult({ runs }, `Found ${runs.length} scheduled task runs.`) + } + + if (input.action === 'create') { + const upsertCronJob = requirePort(this.runtimePort.upsertCronJob, 'upsertCronJob') + const job = await upsertCronJob(toCreateInput(input)) + return createResult({ job }, `Created scheduled task "${job.name}".`, false, { + job: toModelJob(job) + }) + } + + if (input.action === 'update') { + const upsertCronJob = requirePort(this.runtimePort.upsertCronJob, 'upsertCronJob') + const jobId = requireJobId(input) + const result = await listCronJobs() + const existing = result.jobs.find((item) => item.id === jobId) + if (!existing) { + throw new Error(`Cron job not found: ${jobId}`) + } + const job = await upsertCronJob(toUpdateInput(existing, input.patch)) + return createResult({ job }, `Updated scheduled task "${job.name}".`, false, { + job: toModelJob(job) + }) + } + + if (input.action === 'pause' || input.action === 'resume') { + const toggleCronJob = requirePort(this.runtimePort.toggleCronJob, 'toggleCronJob') + const job = await toggleCronJob(requireJobId(input), input.action === 'resume') + return createResult( + { job }, + `${input.action === 'resume' ? 'Resumed' : 'Paused'} scheduled task "${job.name}".`, + false, + { job: toModelJob(job) } + ) + } + + if (input.action === 'delete') { + const deleteCronJob = requirePort(this.runtimePort.deleteCronJob, 'deleteCronJob') + await deleteCronJob(requireJobId(input)) + return createResult({ deleted: true, jobId: input.jobId }, 'Deleted scheduled task.') + } + + const runCronJobNow = requirePort(this.runtimePort.runCronJobNow, 'runCronJobNow') + const run = await runCronJobNow(requireJobId(input)) + return createResult({ run }, `Started scheduled task run ${run.id}.`) + } +} diff --git a/src/main/presenter/toolPresenter/agentTools/index.ts b/src/main/presenter/toolPresenter/agentTools/index.ts index ac116f139..760679537 100644 --- a/src/main/presenter/toolPresenter/agentTools/index.ts +++ b/src/main/presenter/toolPresenter/agentTools/index.ts @@ -18,3 +18,8 @@ export { TAPE_TOOL_NAMES, AgentTapeToolHandler } from './agentTapeTools' +export { + CRON_JOB_TOOL_SERVER_NAME, + CronJobToolHandler, + cronJobActionNeedsPermission +} from './cronJobTool' diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index eb032b7b7..60ed275d9 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -10,12 +10,14 @@ import type { PermissionMode } from '@shared/types/agent-interface' import { resolveToolOffloadTemplatePath } from '@/lib/agentRuntime/sessionPaths' import { QUESTION_TOOL_NAME } from '@/lib/agentRuntime/questionTool' import { ToolMapper, type ToolSource } from './toolMapper' +import { CRON_JOB_AGENT_TOOL_NAME } from '@shared/agentTools' import { AgentToolManager, IMAGE_GENERATE_TOOL_NAME, UPDATE_PLAN_TOOL_NAME, AGENT_TAPE_TOOL_SERVER_NAME, TAPE_TOOL_NAMES, + CRON_JOB_TOOL_SERVER_NAME, type AgentToolCallResult } from './agentTools' import type { AgentToolRuntimePort } from './runtimePorts' @@ -107,6 +109,7 @@ const RESERVED_AGENT_TOOL_NAMES = new Set([ ...YO_BROWSER_TOOL_NAMES, IMAGE_GENERATE_TOOL_NAME, UPDATE_PLAN_TOOL_NAME, + CRON_JOB_AGENT_TOOL_NAME, ...Object.values(TAPE_TOOL_NAMES) ]) @@ -553,6 +556,7 @@ export class ToolPresenter implements IToolPresenter { this.buildImageGenerationPrompt(toolNames), this.buildProgressPrompt(toolNames), this.buildTapePrompt(groupedTools.get(AGENT_TAPE_TOOL_SERVER_NAME) ?? []), + this.buildCronJobPrompt(groupedTools.get(CRON_JOB_TOOL_SERVER_NAME) ?? []), this.buildSkillsPrompt(toolNames), this.buildSettingsPrompt(groupedTools.get('deepchat-settings') ?? []), this.buildYoBrowserPrompt(groupedTools.get('yobrowser') ?? []) @@ -782,6 +786,18 @@ export class ToolPresenter implements IToolPresenter { return lines.join('\n') } + private buildCronJobPrompt(tools: MCPToolDefinition[]): string { + if (tools.length === 0) { + return '' + } + + return [ + '## Scheduled Task Tool', + `Use \`${CRON_JOB_AGENT_TOOL_NAME}\` only when the user explicitly asks to create, inspect, run, pause, resume, update, delete, or preview Scheduled tasks.`, + 'Scheduled task deliveries are notification-only and do not continue normal Remote conversations.' + ].join('\n') + } + private buildSettingsPrompt(tools: MCPToolDefinition[]): string { if (tools.length === 0) { return '' diff --git a/src/main/presenter/toolPresenter/runtimePorts.ts b/src/main/presenter/toolPresenter/runtimePorts.ts index 44f5e39c0..bc63ad32c 100644 --- a/src/main/presenter/toolPresenter/runtimePorts.ts +++ b/src/main/presenter/toolPresenter/runtimePorts.ts @@ -23,6 +23,16 @@ import type { ISkillPresenter } from '@shared/types/skill' import type { AgentMemoryCategory } from '@shared/types/agent-memory' import type { DeepChatInternalSessionUpdate } from '../agentRuntimePresenter/internalSessionEvents' import type { MemoryWriteOutcome } from '../memoryPresenter/types' +import type { + CronJob, + CronJobRun, + CronJobsSchedulerStatus, + CronSchedulePreview +} from '@shared/cronJobs' +import type { cronJobsUpsertInputSchema } from '@shared/contracts/routes/cronJobs.routes' +import type { z } from 'zod' + +export type AgentToolCronJobUpsertInput = z.input export interface ConversationSessionInfo { sessionId: string @@ -101,6 +111,17 @@ export interface AgentToolRuntimePort { query: string ): Promise> forgetMemory?(agentId: string, memoryId: string): Promise + listCronJobs?(): Promise<{ jobs: CronJob[]; schedulerStatus: CronJobsSchedulerStatus }> + upsertCronJob?(input: AgentToolCronJobUpsertInput): Promise + deleteCronJob?(id: string): Promise + toggleCronJob?(id: string, enabled: boolean): Promise + runCronJobNow?(id: string): Promise + listCronJobRuns?(jobId: string, limit?: number): Promise + previewCronSchedule?(input: { + cronExpr: string + timezone: string + count?: number + }): Promise createSubagentSession(input: CreateSubagentSessionInput): Promise mergeSubagentTape?( parentSessionId: string, diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index a93b27c9f..4e03c0a87 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -50,17 +50,37 @@ import { configAddSystemPromptRoute, configClearDefaultSystemPromptRoute, configDeleteCustomPromptRoute, + configDeleteDeepChatAgentRoute, configDeleteSystemPromptRoute, + configRemoveManualAcpAgentRoute, configResetDefaultSystemPromptRoute, configResetShortcutKeysRoute, + configSetAcpAgentEnabledRoute, + configSetAcpEnabledRoute, configSetAcpSharedMcpSelectionsRoute, configSetCustomPromptsRoute, configSetDefaultSystemPromptIdRoute, configSetDefaultSystemPromptRoute, configSetKnowledgeConfigsRoute, configSetSystemPromptsRoute, + configUninstallAcpRegistryAgentRoute, configUpdateCustomPromptRoute, + configUpdateDeepChatAgentRoute, + configUpdateManualAcpAgentRoute, configUpdateSystemPromptRoute, + cronJobsDeleteRoute, + cronJobsGetRunRoute, + cronJobsGetSchedulerStatusRoute, + cronJobsListDeliveriesRoute, + cronJobsListRoute, + cronJobsListRunsRoute, + cronJobsPreviewScheduleRoute, + cronJobsReconcileSchedulerRoute, + cronJobsRestartSchedulerRoute, + cronJobsRunNowRoute, + cronJobsToggleRoute, + cronJobsValidateScheduleRoute, + cronJobsUpsertRoute, databaseSecurityChangePasswordRoute, databaseSecurityDisableRoute, databaseSecurityEnableRoute, @@ -399,15 +419,8 @@ import type { AgentMemoryRow } from '@/presenter/sqlitePresenter/tables/agentMem import type { AgentMemoryAuditRow } from '@/presenter/sqlitePresenter/tables/agentMemoryAudit' import type { DeepChatTapeEntryRow } from '@/presenter/sqlitePresenter/tables/deepchatTapeEntries' import type { SQLitePresenter } from '@/presenter/sqlitePresenter' -import type { ScheduledTasksService } from '@/presenter/scheduledTasks' +import type { CronJobsService } from '@/presenter/cronJobs' import { killTerminal, writeToTerminal } from '@/presenter/configPresenter/acpInitHelper' -import { - scheduledTasksDeleteRoute, - scheduledTasksFireNowRoute, - scheduledTasksListRoute, - scheduledTasksToggleRoute, - scheduledTasksUpsertRoute -} from '@shared/contracts/routes/scheduledTasks.routes' const MEMORY_PERSONA_STATES = ['draft', 'active', 'superseded', 'rejected'] as const type MemoryPersonaState = (typeof MEMORY_PERSONA_STATES)[number] @@ -446,7 +459,7 @@ export type MainKernelRouteRuntime = { pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter memoryPresenter: MemoryPresenter - scheduledTasks: ScheduledTasksService + cronJobs: CronJobsService } function parseSourceEntryIds(raw: string | null): number[] | null { @@ -497,6 +510,30 @@ function normalizeMemoryCategory(value: unknown) { return isAgentMemoryCategory(value) ? value : null } +const CRON_JOB_AGENT_CHANGE_ROUTES: ReadonlySet = new Set([ + configSetAcpEnabledRoute.name, + configSetAcpAgentEnabledRoute.name, + configUninstallAcpRegistryAgentRoute.name, + configUpdateManualAcpAgentRoute.name, + configRemoveManualAcpAgentRoute.name, + configUpdateDeepChatAgentRoute.name, + configDeleteDeepChatAgentRoute.name +]) + +async function reconcileCronJobsAfterAgentChange( + runtime: MainKernelRouteRuntime, + routeName: string +): Promise { + if (!CRON_JOB_AGENT_CHANGE_ROUTES.has(routeName)) { + return + } + try { + await runtime.cronJobs.reconcileScheduler('agent-change') + } catch (error) { + console.warn('[CronJobs] Failed to reconcile jobs after agent change:', error) + } +} + export function toMemoryItemDto(row: AgentMemoryRow) { return { id: row.id, @@ -706,7 +743,7 @@ export function createMainKernelRouteRuntime(deps: { pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter memoryPresenter: MemoryPresenter - scheduledTasks: ScheduledTasksService + cronJobs: CronJobsService }): MainKernelRouteRuntime { const scheduler = createNodeScheduler() const hotPathPorts = createPresenterHotPathPorts({ @@ -731,30 +768,87 @@ export function createMainKernelRouteRuntime(deps: { scheduler }) - // Wire scheduled tasks -> sessions for the auto-send action. - deps.scheduledTasks.setSessionCreator({ - async createSessionForTask(input) { - const session = await sessionService.createSession( - { - agentId: input.agentId, - message: input.message, - providerId: input.providerId, - modelId: input.modelId, - ...(input.systemPrompt - ? { generationSettings: { systemPrompt: input.systemPrompt } } - : {}) - }, - { - webContentsId: deps.windowPresenter.mainWindow?.webContents?.id ?? -1, - windowId: deps.windowPresenter.mainWindow?.id ?? null - } - ) - if (!session?.id) { - return { sessionId: null } + deps.cronJobs.setRunSessionStarter({ + async createSessionForRun({ job, run }) { + if (!job.agentId) { + throw new Error('Cron job requires an enabled agent.') } - - await chatService.sendMessage(session.id, input.message) + console.info('[CronJobs] Resolving agent for session:', { + jobId: job.id, + runId: run.id, + agentId: job.agentId + }) + const agentType = await deps.configPresenter.getAgentType(job.agentId) + const snapshotConfig = job.agentSnapshot?.config as + | { + defaultModelPreset?: { providerId?: string; modelId?: string } | null + permissionMode?: 'default' | 'auto_approve' | 'full_access' + disabledAgentTools?: string[] + subagentEnabled?: boolean + systemPrompt?: string + } + | null + | undefined + const modelPreset = + agentType === 'deepchat' && job.modelPolicy === 'pin_current' + ? snapshotConfig?.defaultModelPreset + : null + const systemPrompt = job.taskSystemInstruction?.trim() || snapshotConfig?.systemPrompt + + const session = await deps.agentSessionPresenter.createDetachedSession({ + agentId: job.agentId, + title: job.name, + ...(agentType === 'acp' ? { providerId: 'acp', modelId: job.agentId } : {}), + ...(modelPreset?.providerId ? { providerId: modelPreset.providerId } : {}), + ...(modelPreset?.modelId ? { modelId: modelPreset.modelId } : {}), + ...(job.permissionPolicy === 'snapshot' && snapshotConfig?.permissionMode + ? { permissionMode: snapshotConfig.permissionMode } + : {}), + ...(job.toolPolicy === 'snapshot' && snapshotConfig?.disabledAgentTools + ? { disabledAgentTools: snapshotConfig.disabledAgentTools } + : {}), + ...(snapshotConfig?.subagentEnabled !== undefined + ? { subagentEnabled: snapshotConfig.subagentEnabled } + : {}), + ...(systemPrompt ? { generationSettings: { systemPrompt } } : {}), + metadata: { + source: 'cron_job', + cronJobId: job.id, + cronJobRunId: run.id, + scheduledAt: run.scheduledAt + } + }) + console.info('[CronJobs] Detached session created:', { + jobId: job.id, + runId: run.id, + sessionId: session.id, + agentType + }) return { sessionId: session.id } + }, + async startSessionRun({ job, sessionId }) { + if (!job.taskPrompt.trim()) { + throw new Error('Cron job task prompt is empty.') + } + console.info('[CronJobs] Sending task prompt to session:', { + jobId: job.id, + sessionId, + promptLength: job.taskPrompt.length + }) + const result = await deps.agentSessionPresenter.sendMessage(sessionId, job.taskPrompt, { + maxProviderRounds: job.runtime.maxTurns + }) + console.info('[CronJobs] Task prompt accepted by session:', { + jobId: job.id, + sessionId, + outputMessageId: result.messageId ?? null + }) + return { + outputMessageId: result.messageId ?? null + } + }, + async cancelSessionRun({ sessionId }) { + await deps.agentSessionPresenter.cancelGeneration(sessionId) } }) @@ -812,7 +906,7 @@ export function createMainKernelRouteRuntime(deps: { pluginPresenter: deps.pluginPresenter, databaseSecurityPresenter: deps.databaseSecurityPresenter, memoryPresenter: deps.memoryPresenter, - scheduledTasks: deps.scheduledTasks + cronJobs: deps.cronJobs } } @@ -1433,6 +1527,7 @@ export async function dispatchDeepchatRoute( const configResult = await dispatchConfigRoute(runtime.configPresenter, routeName, rawInput) if (configResult !== undefined) { recordConfigRouteActivity(runtime, routeName, rawInput) + await reconcileCronJobsAfterAgentChange(runtime, routeName) return configResult } @@ -2567,34 +2662,80 @@ export async function dispatchDeepchatRoute( }) } - case scheduledTasksListRoute.name: { - scheduledTasksListRoute.input.parse(rawInput) - const settings = runtime.scheduledTasks.list() - return scheduledTasksListRoute.output.parse({ settings }) + case cronJobsListRoute.name: { + cronJobsListRoute.input.parse(rawInput) + const { jobs, schedulerStatus } = await runtime.cronJobs.list() + return cronJobsListRoute.output.parse({ jobs, schedulerStatus }) + } + + case cronJobsUpsertRoute.name: { + const input = cronJobsUpsertRoute.input.parse(rawInput) + const { job, schedulerStatus } = await runtime.cronJobs.upsert(input) + return cronJobsUpsertRoute.output.parse({ job, schedulerStatus }) + } + + case cronJobsDeleteRoute.name: { + const input = cronJobsDeleteRoute.input.parse(rawInput) + const schedulerStatus = await runtime.cronJobs.delete(input.id) + return cronJobsDeleteRoute.output.parse({ schedulerStatus }) + } + + case cronJobsToggleRoute.name: { + const input = cronJobsToggleRoute.input.parse(rawInput) + const { job, schedulerStatus } = await runtime.cronJobs.toggle(input.id, input.enabled) + return cronJobsToggleRoute.output.parse({ job, schedulerStatus }) + } + + case cronJobsRunNowRoute.name: { + const input = cronJobsRunNowRoute.input.parse(rawInput) + const { job, run, schedulerStatus } = await runtime.cronJobs.runNow(input.id) + return cronJobsRunNowRoute.output.parse({ job, run, schedulerStatus }) + } + + case cronJobsListRunsRoute.name: { + const input = cronJobsListRunsRoute.input.parse(rawInput) + const runs = runtime.cronJobs.listRuns(input.jobId, input.limit) + return cronJobsListRunsRoute.output.parse({ runs }) + } + + case cronJobsGetRunRoute.name: { + const input = cronJobsGetRunRoute.input.parse(rawInput) + return cronJobsGetRunRoute.output.parse({ run: runtime.cronJobs.getRun(input.runId) }) + } + + case cronJobsListDeliveriesRoute.name: { + const input = cronJobsListDeliveriesRoute.input.parse(rawInput) + return cronJobsListDeliveriesRoute.output.parse({ + deliveries: runtime.cronJobs.listDeliveries(input.runId) + }) + } + + case cronJobsGetSchedulerStatusRoute.name: { + cronJobsGetSchedulerStatusRoute.input.parse(rawInput) + const schedulerStatus = runtime.cronJobs.getSchedulerStatus() + return cronJobsGetSchedulerStatusRoute.output.parse({ schedulerStatus }) } - case scheduledTasksUpsertRoute.name: { - const input = scheduledTasksUpsertRoute.input.parse(rawInput) - const { task, settings } = runtime.scheduledTasks.upsert(input) - return scheduledTasksUpsertRoute.output.parse({ task, settings }) + case cronJobsReconcileSchedulerRoute.name: { + const input = cronJobsReconcileSchedulerRoute.input.parse(rawInput) + const schedulerStatus = await runtime.cronJobs.reconcileScheduler(input.reason) + return cronJobsReconcileSchedulerRoute.output.parse({ schedulerStatus }) } - case scheduledTasksDeleteRoute.name: { - const input = scheduledTasksDeleteRoute.input.parse(rawInput) - const settings = runtime.scheduledTasks.delete(input.id) - return scheduledTasksDeleteRoute.output.parse({ settings }) + case cronJobsRestartSchedulerRoute.name: { + cronJobsRestartSchedulerRoute.input.parse(rawInput) + const schedulerStatus = await runtime.cronJobs.restartScheduler() + return cronJobsRestartSchedulerRoute.output.parse({ schedulerStatus }) } - case scheduledTasksToggleRoute.name: { - const input = scheduledTasksToggleRoute.input.parse(rawInput) - const { task, settings } = runtime.scheduledTasks.toggle(input.id, input.enabled) - return scheduledTasksToggleRoute.output.parse({ task, settings }) + case cronJobsValidateScheduleRoute.name: { + const input = cronJobsValidateScheduleRoute.input.parse(rawInput) + return cronJobsValidateScheduleRoute.output.parse(runtime.cronJobs.validateSchedule(input)) } - case scheduledTasksFireNowRoute.name: { - const input = scheduledTasksFireNowRoute.input.parse(rawInput) - const { task, settings } = await runtime.scheduledTasks.fireNow(input.id) - return scheduledTasksFireNowRoute.output.parse({ task, settings }) + case cronJobsPreviewScheduleRoute.name: { + const input = cronJobsPreviewScheduleRoute.input.parse(rawInput) + return cronJobsPreviewScheduleRoute.output.parse(runtime.cronJobs.previewSchedule(input)) } case startupGetBootstrapRoute.name: { diff --git a/src/main/schedulerUtilityHostEntry.ts b/src/main/schedulerUtilityHostEntry.ts new file mode 100644 index 000000000..628394f2e --- /dev/null +++ b/src/main/schedulerUtilityHostEntry.ts @@ -0,0 +1,5 @@ +import { runSchedulerUtilityHostIfRequested } from './presenter/cronJobs/schedulerUtilityHost' + +if (!runSchedulerUtilityHostIfRequested()) { + throw new Error('Cron scheduler utility host entrypoint started outside a utility process.') +} diff --git a/src/renderer/api/CronJobsClient.ts b/src/renderer/api/CronJobsClient.ts new file mode 100644 index 000000000..89f9d30cd --- /dev/null +++ b/src/renderer/api/CronJobsClient.ts @@ -0,0 +1,187 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { + cronJobRunSchema, + cronJobSchema, + cronJobsDeleteRoute, + cronJobsGetRunRoute, + cronJobsGetSchedulerStatusRoute, + cronJobsListDeliveriesRoute, + cronJobsListRoute, + cronJobsListRunsRoute, + cronJobsPreviewScheduleRoute, + cronJobsReconcileSchedulerRoute, + cronJobsRestartSchedulerRoute, + cronJobsRunNowRoute, + cronJobsSchedulerStatusSchema, + cronJobsToggleRoute, + cronJobsValidateScheduleRoute, + cronJobsUpsertRoute, + type cronJobsUpsertInputSchema +} from '@shared/contracts/routes/cronJobs.routes' +import type { z } from 'zod' +import { getDeepchatBridge } from './core' + +export type CronJobsUpsertInput = z.input + +const toPlainIpcValue = (value: T): T => { + if (value === null || typeof value !== 'object') { + return value + } + if (value instanceof Date) { + return new Date(value.getTime()) as T + } + if (Array.isArray(value)) { + return value.map((item) => toPlainIpcValue(item)) as T + } + + const plain: Record = {} + for (const [key, nestedValue] of Object.entries(value as Record)) { + plain[key] = toPlainIpcValue(nestedValue) + } + return plain as T +} + +const parseJobResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[CronJobsClient] Invalid response shape from ${routeName}`) + } + const parsed = cronJobSchema.safeParse((result as { job?: unknown }).job) + if (!parsed.success) { + throw new Error(`[CronJobsClient] Invalid job response from ${routeName}`) + } + return parsed.data +} + +const parseRunResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[CronJobsClient] Invalid response shape from ${routeName}`) + } + const parsed = cronJobRunSchema.safeParse((result as { run?: unknown }).run) + if (!parsed.success) { + throw new Error(`[CronJobsClient] Invalid run response from ${routeName}`) + } + return parsed.data +} + +const parseSchedulerStatusResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[CronJobsClient] Invalid response shape from ${routeName}`) + } + const parsed = cronJobsSchedulerStatusSchema.safeParse( + (result as { schedulerStatus?: unknown }).schedulerStatus + ) + if (!parsed.success) { + throw new Error(`[CronJobsClient] Invalid scheduler status response from ${routeName}`) + } + return parsed.data +} + +export function createCronJobsClient(bridge: DeepchatBridge = getDeepchatBridge()) { + async function list() { + const result = await bridge.invoke(cronJobsListRoute.name, {}) + if (typeof result !== 'object' || result === null) { + throw new Error('[CronJobsClient] Invalid response shape from cronJobs.list') + } + const jobs = cronJobSchema.array().parse((result as { jobs?: unknown }).jobs) + return { + jobs, + schedulerStatus: parseSchedulerStatusResponse(cronJobsListRoute.name, result) + } + } + + async function upsert(input: CronJobsUpsertInput) { + const result = await bridge.invoke(cronJobsUpsertRoute.name, toPlainIpcValue(input)) + return { + job: parseJobResponse(cronJobsUpsertRoute.name, result), + schedulerStatus: parseSchedulerStatusResponse(cronJobsUpsertRoute.name, result) + } + } + + async function remove(id: string) { + const result = await bridge.invoke(cronJobsDeleteRoute.name, { id }) + return parseSchedulerStatusResponse(cronJobsDeleteRoute.name, result) + } + + async function toggle(id: string, enabled: boolean) { + const result = await bridge.invoke(cronJobsToggleRoute.name, { id, enabled }) + return { + job: parseJobResponse(cronJobsToggleRoute.name, result), + schedulerStatus: parseSchedulerStatusResponse(cronJobsToggleRoute.name, result) + } + } + + async function runNow(id: string) { + const result = await bridge.invoke(cronJobsRunNowRoute.name, { id }) + return { + job: parseJobResponse(cronJobsRunNowRoute.name, result), + run: parseRunResponse(cronJobsRunNowRoute.name, result), + schedulerStatus: parseSchedulerStatusResponse(cronJobsRunNowRoute.name, result) + } + } + + async function listRuns(jobId: string, limit?: number) { + const result = await bridge.invoke(cronJobsListRunsRoute.name, { jobId, limit }) + return cronJobsListRunsRoute.output.parse(result).runs + } + + async function getRun(runId: string) { + const result = await bridge.invoke(cronJobsGetRunRoute.name, { runId }) + return parseRunResponse(cronJobsGetRunRoute.name, result) + } + + async function listDeliveries(runId: string) { + return cronJobsListDeliveriesRoute.output.parse( + await bridge.invoke(cronJobsListDeliveriesRoute.name, { runId }) + ).deliveries + } + + async function getSchedulerStatus() { + const result = await bridge.invoke(cronJobsGetSchedulerStatusRoute.name, {}) + return parseSchedulerStatusResponse(cronJobsGetSchedulerStatusRoute.name, result) + } + + async function reconcileScheduler(reason?: string) { + const result = await bridge.invoke(cronJobsReconcileSchedulerRoute.name, { reason }) + return parseSchedulerStatusResponse(cronJobsReconcileSchedulerRoute.name, result) + } + + async function restartScheduler() { + const result = await bridge.invoke(cronJobsRestartSchedulerRoute.name, {}) + return parseSchedulerStatusResponse(cronJobsRestartSchedulerRoute.name, result) + } + + async function validateSchedule(input: { cronExpr: string; timezone: string; from?: number }) { + return cronJobsValidateScheduleRoute.output.parse( + await bridge.invoke(cronJobsValidateScheduleRoute.name, input) + ) + } + + async function previewSchedule(input: { + cronExpr: string + timezone: string + count?: number + from?: number + }) { + return cronJobsPreviewScheduleRoute.output.parse( + await bridge.invoke(cronJobsPreviewScheduleRoute.name, input) + ) + } + + return { + list, + upsert, + remove, + toggle, + runNow, + listRuns, + getRun, + listDeliveries, + getSchedulerStatus, + reconcileScheduler, + restartScheduler, + validateSchedule, + previewSchedule + } +} + +export type CronJobsClient = ReturnType diff --git a/src/renderer/api/ScheduledTasksClient.ts b/src/renderer/api/ScheduledTasksClient.ts deleted file mode 100644 index e41a88b26..000000000 --- a/src/renderer/api/ScheduledTasksClient.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { DeepchatBridge } from '@shared/contracts/bridge' -import { - scheduledTasksDeleteRoute, - scheduledTasksFireNowRoute, - scheduledTasksListRoute, - scheduledTasksToggleRoute, - scheduledTasksUpsertRoute, - scheduledTasksSettingsSchema, - scheduledTaskSchema, - type scheduledTasksUpsertInputSchema -} from '@shared/contracts/routes/scheduledTasks.routes' -import type { z } from 'zod' -import { getDeepchatBridge } from './core' - -export type ScheduledTasksUpsertInput = z.input - -const parseSettingsResponse = (routeName: string, result: unknown) => { - if (typeof result !== 'object' || result === null) { - throw new Error(`[ScheduledTasksClient] Invalid response shape from ${routeName}`) - } - const maybe = (result as { settings?: unknown }).settings - const parsed = scheduledTasksSettingsSchema.safeParse(maybe) - if (!parsed.success) { - throw new Error(`[ScheduledTasksClient] Invalid settings response from ${routeName}`) - } - return parsed.data -} - -const parseTaskResponse = (routeName: string, result: unknown) => { - if (typeof result !== 'object' || result === null) { - throw new Error(`[ScheduledTasksClient] Invalid response shape from ${routeName}`) - } - const maybeTask = (result as { task?: unknown }).task - const parsedTask = scheduledTaskSchema.safeParse(maybeTask) - if (!parsedTask.success) { - throw new Error(`[ScheduledTasksClient] Invalid task response from ${routeName}`) - } - return parsedTask.data -} - -export function createScheduledTasksClient(bridge: DeepchatBridge = getDeepchatBridge()) { - async function list() { - const result = await bridge.invoke(scheduledTasksListRoute.name, {}) - return parseSettingsResponse(scheduledTasksListRoute.name, result) - } - - async function upsert(input: ScheduledTasksUpsertInput) { - const result = await bridge.invoke(scheduledTasksUpsertRoute.name, input) - return { - task: parseTaskResponse(scheduledTasksUpsertRoute.name, result), - settings: parseSettingsResponse(scheduledTasksUpsertRoute.name, result) - } - } - - async function remove(id: string) { - const result = await bridge.invoke(scheduledTasksDeleteRoute.name, { id }) - return parseSettingsResponse(scheduledTasksDeleteRoute.name, result) - } - - async function toggle(id: string, enabled: boolean) { - const result = await bridge.invoke(scheduledTasksToggleRoute.name, { id, enabled }) - return { - task: parseTaskResponse(scheduledTasksToggleRoute.name, result), - settings: parseSettingsResponse(scheduledTasksToggleRoute.name, result) - } - } - - async function fireNow(id: string) { - const result = await bridge.invoke(scheduledTasksFireNowRoute.name, { id }) - return { - task: parseTaskResponse(scheduledTasksFireNowRoute.name, result), - settings: parseSettingsResponse(scheduledTasksFireNowRoute.name, result) - } - } - - return { - list, - upsert, - remove, - toggle, - fireNow - } -} - -export type ScheduledTasksClient = ReturnType diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index a231230dd..4d032a374 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -4,6 +4,7 @@ export * from './BrowserClient' export * from './ConfigClient' export * from './ChatClient' export * from './ContextMenuClient' +export * from './CronJobsClient' export * from './DeviceClient' export * from './DebugClient' export * from './DialogClient' @@ -19,7 +20,6 @@ export * from './PluginClient' export * from './ProviderClient' export * from './ProjectClient' export * from './RemoteControlClient' -export * from './ScheduledTasksClient' export * from './SessionClient' export * from './SettingsClient' export * from './ShortcutClient' diff --git a/src/renderer/settings/components/CronJobsSettings.vue b/src/renderer/settings/components/CronJobsSettings.vue new file mode 100644 index 000000000..df0e6469b --- /dev/null +++ b/src/renderer/settings/components/CronJobsSettings.vue @@ -0,0 +1,960 @@ +