Home

mweinbach / agent-coworker

publicmweinbach/agent-coworker
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues

feat: support custom provider model IDs (#198)

3 months ago

9cbb272
Authored
Max Weinbach7/5/2026, 12:53:14 PM
* feat: support custom provider model IDs

Add persistent custom model ID storage and merge configured entries into provider catalogs for OpenAI, Google, Anthropic, NVIDIA, Together, Fireworks, Baseten, MiniMax, OpenCode, Bedrock, and Antigravity providers.

Expose JSON-RPC add/delete controls, update generated protocol docs, and allow configured custom IDs through model metadata and runtime resolution with conservative fallbacks.

Refresh the desktop Models UI with inline custom model controls, compact available-model summaries, and selector-side custom ID entry for supported providers.

Tests: bun run docs:generate-jsonrpc; focused custom-model tests; bun run typecheck; bun run docs:check; bun run check; bun run lint; bun run test.

* test: harden Windows temp cleanup

* fix: preserve selected reasoning effort during hydration

Keep an explicit composer reasoning effort visible while stale session_config payloads arrive, and prefer live runtime reasoning metadata for active OpenAI-compatible threads before falling back to persisted provider options.

Add desktop reducer and ChatView regressions covering the XHigh-to-Medium snapback.

* fix: flatten assistant markdown table chrome

Disable Streamdown table controls in desktop markdown and flatten its table wrapper so assistant tables stay inside the message flow without extra card chrome.

Add a desktop markdown regression that verifies table controls are absent and wrapping classes are applied.

* feat: render mermaid diagrams and inline images in chat markdown

- Fix mermaid fences: PreWithCopy now forwards Streamdown's data-block
  marker for language-mermaid so the diagram renderer engages (was
  falling through to inline code); keep hover copy button for other
  fences. Mermaid controls trimmed to fullscreen + pan/zoom and diagram
  theme follows the app dark/light mode.
- Add cowork-media: Electron protocol serving local image files with an
  image-extension allowlist, traversal normalization, and testable URL
  encode/decode helpers shared between renderer and main.
- Rewrite markdown image sources (absolute paths, file:// URLs, and
  workspace-relative paths via desktopBasePath) to cowork-media at the
  remark stage; extend sanitize schema with img alt/title and the
  cowork-media src protocol.
- Add DesktopMarkdownImage: constrained inline rendering with
  click-to-preview for local files, confirm-and-open for remote URLs,
  and a file-chip fallback when loading fails.
- Document inline images and mermaid diagrams in system prompts so the
  model uses them for charts, screenshots, and architecture sketches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: add global model preferences store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: apply model preferences to provider catalogs

Discovered models outside the curated registry default to disabled;
custom models and codex app-server models stay enabled. Overrides from
model-preferences.json win either way, with fail-open when curation and
discovery do not overlap and defaultModel repair when the default is
disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add provider model setEnabled/resetEnabled JSON-RPC controls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add per-provider Manage models UI with enable/disable checkboxes

Desktop settings gains a Manage models dialog per provider (search,
checkbox toggles, enable/disable all, reset to defaults, custom model
add/remove). Pickers across desktop and mobile hide models flagged
enabled:false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: unexport internal model-preference symbols

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: curate default-enabled models for open-model providers

Together, NVIDIA, MiniMax, Baseten, Fireworks, Fire Pass, and OpenCode
catalogs now default to Nemotron 3 Ultra, MiniMax M3, GLM 5.2,
Kimi K2.6, DeepSeek V4 Pro, and DeepSeek V4 Flash (matching
provider-specific id spellings such as kimi-k2p6). Catalogs carrying
none of the curated set fall back to registry defaults so a provider
never goes dark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update CLAUDE.md

* fix: report custom-model and preference mutation outcomes to the UI

Await custom-model adds before selecting them in the composer, restore
the typed ID when an add fails, and roll back optimistic Manage models
checkbox state when the setEnabled RPC fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: resolve configured custom model IDs in sync resume and normalization

Persisted sessions no longer migrate a configured custom model to the
provider default on resume, and normalizeModelIdForProvider accepts a
configured custom ID even when it also exists in another provider's
registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: use exact PI lookups with conservative custom-model fallbacks

Unknown custom OpenAI and Anthropic IDs no longer inherit the first
catalog model's pricing/metadata; they resolve through dedicated
fallback builders with unknown cost and conservative limits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: serialize global model store writes across processes

Custom-model and model-preference mutations now take a sidecar file
lock (mkdir + pid stale takeover, mirroring SessionDbWriteCoordinator)
so concurrent workspace servers cannot drop each other's writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: constrain chat media rendering to approved workspace roots

The cowork-media protocol now enforces the same resolveAllowedPath
boundary as the file IPC handlers, and markdown image resolution blocks
relative paths that escape the workspace base instead of letting them
resolve outside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: mark catalog models duplicating a custom ID as custom-managed

A custom ID that collides with a discovered or static catalog model
keeps its discovered metadata but carries the custom marker, so clients
can surface the store entry for removal instead of hiding it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: compare pending reasoning effort against the draft composer provider and reconcile Manage models pending state

session_config no longer clears a pending reasoning effort using the
live config provider when the composer is showing a draft provider, and
the Manage models dialog reconciles optimistic checkbox state per model
instead of wiping all in-flight toggles on each catalog refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: harden store lock and thread auth home into custom-model resume

- Never take over a store file lock while its owner process is alive; a
  slow-but-live owner surfaces as an acquire timeout instead of a
  takeover that would reopen the lost-update race.
- Resolve persisted-session custom models against the session auth home
  (resolveAuthHomeDir(baseConfig)) so non-default homedirs are honored.
- Resume configured Bedrock custom IDs missing from the static/cache
  snapshot as placeholders instead of migrating to the provider default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: label disabled built-ins correctly and rewrite raw-HTML image srcs

- A selected-but-disabled built-in model no longer shows the '(custom)'
  suffix reserved for user-added custom IDs in the composer picker.
- Raw HTML <img> tags with workspace-relative srcs are rewritten to
  cowork-media at a new rehype stage (before harden) and resolved against
  the workspace base path, so they render like markdown images while
  escaping paths stay blocked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: unexport internal rehypeRewriteDesktopImages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: keep active disabled model visible on mobile and drop blocked image srcs

- Mobile model pickers keep the current default pill visible even when
  that model is disabled, matching the desktop composer.
- DesktopMarkdownImage resolves via the tree resolver so a base-escaping
  src is dropped ('') instead of falling back to the raw escaping path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: clear pending toggles when resetting Manage models to defaults

Reset discards local intent, so it now clears optimistic pendingById
immediately; otherwise a still-in-flight toggle survived the reconcile
and left a stale checkbox after the server restored defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: thread session auth home through sync model resolution and preserve discovered IDs on resume

- Thread resolveAuthHomeDir(config) through every sync
  normalizeModelIdForProvider / child-routing caller (config.getModel,
  getModelForProvider, modelRouter, ProviderAuthManager,
  SessionMetadataManager), so a configured custom cross-registry ID is
  accepted during selection on a non-default-homedir server instead of
  being rejected as foreign.
- Resume now consults the discovery cache (new sync
  readModelDiscoveryCacheSync + isDiscoveredModelIdSync), so a selected
  newly-discovered model is preserved instead of migrating to the
  provider default when the thread reopens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: key the all-disabled hint off the enabled count including custom models

The 'All models are disabled' hint keyed off the standard-only preview
chips, so a provider with all built-ins disabled but custom models
enabled was mislabeled. It now keys off enabledModelCount (custom +
standard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: thread session auth home through remaining custom-model resolution paths

- Thread resolveAuthHomeDir(config) into resolvePiModel's sync metadata
  lookups (applySupportedModelMetadata) so a configured custom
  cross-registry ID resolves on the first turn under a non-default home.
- Pass the session home into mergeProviderOptionDefaults so config load
  doesn't throw while merging provider defaults for a custom ID.
- The post-normalization child-model guard now keeps a custom-only
  preferred child model (consulting the custom store), not just
  discovered ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: thread session auth home through remaining runtime/prompt model lookups

Extends the auth-home threading to every remaining sync metadata caller
so a configured custom cross-registry ID resolves under a non-default
homedir instead of aborting the first turn:
- openaiResponsesModel and googleInteractionsModel runtimes
- resolveModelMetadata's allowPlaceholder (prompt-load) fall-through
- prompt.ts system-prompt loading + spawn-agent prompt body
- getKnownResolvedModelMetadata reads the Bedrock discovery snapshot from
  the session home so a discovered Bedrock model resumes correctly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: prefer confirmed config over stale runtime reasoning effort

The composer reasoning selector prioritized the runtime effort captured
at creation/resume (never refreshed on a config ack) over the confirmed
session config, so it snapped back to the old value after a change was
confirmed. Extracted resolveCurrentReasoningEffort with the corrected
precedence (composer -> config -> runtime -> default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: sync runtime reasoning effort on config ack instead of reordering precedence

The prior fix reordered the selector precedence (config before runtime),
which broke the intentional runtime-first behavior (the session may run
an effort that differs from a stale config). Revert to runtime-first and
instead keep the runtime effort in sync: when a pending composer effort
is confirmed by the session_config ack, update requested/effective
reasoning effort to the confirmed value so the selector no longer snaps
back. Guards against Google's 'dynamic' mode (openai/codex-cli only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: resolve a stuck pending reasoning effort when the server settles a different value

The optimistic composer effort was only cleared when the config ack
matched it exactly, so a server that clamped/rejected the change (or a
set_config that never sent) left the selector stuck showing the pending
value over runtime and config. Now clear it when the config settles any
value (matches our request, or changes a previously-defined effort to a
different one), sync the runtime fields to that authoritative value, and
revert the optimistic value if set_config fails to send. The lagging
case (config unchanged) still preserves the pending value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: preserve discovered-model capabilities and drop stray reasoning defaults

- Resolve discovered models (sync resume + async path) from their cached
  entry so a reopened session keeps supportsImageInput/displayName/
  knowledgeCutoff instead of a generic placeholder that downgrades vision.
- getResolvedModelMetadataSync's Bedrock branch forwards opts.home so a
  Bedrock model in the discovery snapshot under a non-default home
  resolves with cached metadata.
- Custom placeholders no longer inherit the provider default's reasoning
  option defaults (reasoningEffort/reasoningSummary) unless the id is
  reasoning-capable, so a custom non-reasoning OpenAI id (gpt-4o) does not
  send a reasoning payload its runtime fallback rejects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: drop stale reasoning options when selecting a non-reasoning model

prepareModelSelection spreads the prior config's providerOptions into the
new config, so switching a live thread from a reasoning model (GPT-5) to
a non-reasoning custom id (gpt-4o) forwarded the old reasoningEffort/
reasoningSummary and the first OpenAI Responses request sent a reasoning
payload the fallback model rejects. Added reconcileReasoningProviderOptions
to strip reasoning keys the newly-resolved model does not declare in its
defaults; a reasoning-capable model keeps a user's explicit effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: preserve discovered capabilities, reconcile config reasoning, correlate catalog mutations, keep fail-open under overrides

- getResolvedModelMetadataSync consults the discovery cache before its
  placeholder, so a runtime turn on a cache-only model keeps vision/
  reasoning capabilities instead of downgrading to text-only.
- mergeProviderOptionDefaults reconciles reasoning options so a config
  that retained reasoningEffort from a prior model does not reintroduce
  it onto a custom non-reasoning id at load.
- Provider catalog mutation routes use captureWorkspaceControlMutationEvents
  and return the last catalog, so the JSON-RPC call cannot settle on a
  concurrent refresh's catalog before the store write completes.
- applyModelPreferencesToCatalogEntry keeps its fail-open baseline under
  a single override, so disabling one model in an all-unknown catalog
  hides only that model, not the whole catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: strip GPT-5-only verbosity for non-GPT-5 ids and stop inheriting reasoning for non-reasoning children

- textVerbosity is a GPT-5-family-only parameter, so a custom/discovered
  non-GPT-5 OpenAI id (gpt-4o, o3) no longer carries it in its placeholder
  defaults; reconciliation uses the combined model-gated key set so stale
  verbosity is dropped on switch/load too. o-series keeps reasoning but not
  verbosity.
- modelRouter no longer falls through to the parent's reasoning effort when
  a child is routed to a different, non-reasoning model: a changed child
  uses its own default effort (undefined for non-reasoning), only an
  unchanged child inherits the parent effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: clear stale runtime reasoning effort when settled config carries none

When a session_config ack cleared an optimistic composer effort but the
incoming config had no reasoningEffort, the runtime fields were left
untouched. With runtime-first selector precedence the composer kept
showing the previous turn's effort instead of the settled config/default.
Distinguish "leave runtime alone" from "clear runtime to null" via an
explicit syncRuntime flag so openai/codex-cli threads always sync their
runtime effort to the authoritative config value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reconcile child reasoning options, gate images on resolved metadata, outwait store locks

Addresses three P2 review findings on PR #198:

- modelRouter: routing a reasoning parent (GPT-5 with reasoningEffort/
  reasoningSummary) to a non-reasoning child (custom/discovered gpt-4o) left
  the stale reasoning keys in the child config because applyReasoningEffort
  returns the copied parent options unchanged. Reconcile against the child
  model's resolved defaults so it drops keys the model does not declare.

- image gates: attachment materialization and the read tool gated visual
  content on the static-registry-only supportsImageInput, so a discovered
  vision model advertised in the catalog/runtime was downgraded to text-only.
  Route both through a new modelSupportsImageInputSync that resolves the same
  dynamic metadata (registry -> discovery cache -> custom store).

- provider mutations: custom-model/model-preference writes wait up to the
  file-lock acquire timeout (10s) for the cross-process store lock, but the
  JSON-RPC mutation capture timed out at 5s and dropped the catalog the store
  later emitted, leaving the UI showing stale state. Pass a capture timeout
  that outwaits the store lock plus a write/emit margin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make file-lock ownership atomic and prefer discovered over custom metadata

Addresses two P2 review findings on PR #198:

- fileLock: the stale-lock reclaim removed an ownerless lock directory based on
  mtime, so a creator paused between mkdir and its owner write could have its
  directory stolen and then, on resume, overwrite the new owner and re-enter the
  critical section — reopening the lost-update race on the model stores. Make the
  owner.json write the atomic mutex (exclusive wx/O_EXCL create): whoever wins
  the owner file owns the lock, an orphaned directory is claimed without an
  mtime reclaim, and a paused creator whose lock was claimed can no longer
  clobber it. Added a deterministic regression test via a new onBeforeOwnerWrite
  test seam.

- metadata: for an id present in both the custom store and the discovery cache,
  strict selection (resolveModelMetadata) and resume (getKnownResolvedModelMetadata)
  returned the generic custom placeholder before consulting the discovery cache,
  losing the cached display/vision/reasoning metadata the catalog advertises for
  custom-managed discovered models. Prefer the discovered entry over the custom
  placeholder in both paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reasoning-metadata fidelity for custom and discovered models

Addresses three P2 review findings on PR #198:

- catalog: a custom-only OpenAI reasoning id (e.g. o3-preview-custom or a gpt-5
  deployment) produced a catalog entry with no reasoning block, so desktop
  reasoningConfigFromCatalog() (which only falls back to static-registry models)
  rendered no reasoning selector even though the runtime treats it as reasoning-
  capable. Derive the entry's reasoning block from the same placeholder
  resolution model selection uses.

- metadata: buildDiscoveredModelMetadata reduced the cache's reasoning info to a
  boolean, so a discovery entry advertising a specific defaultEffort still got
  the provider fallback (e.g. OpenAI "high") in providerOptionsDefaults, which
  config loading and child routing then send. Honor the cached default effort.

- metadata: customModelIdLikelySupportsReasoning returned true for non-OpenAI
  providers, so a user-added non-reasoning custom id (e.g. a legacy claude-3-5
  deployment) inherited the provider default thinking/effort keys and failed the
  first turn with unsupported thinking params. Default unproven custom ids to
  non-reasoning so those keys are stripped; static/discovered models that
  advertise reasoning pass supportsReasoning explicitly and are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Parentbb2ea88

85 files changed
  • CLAUDE.md+32−0
  • apps/desktop/electron/ipc.ts+1−1
  • apps/desktop/electron/ipc/types.ts+7−0
  • apps/desktop/electron/main.ts+14−1
  • apps/desktop/electron/services/mediaProtocol.ts+117−0
  • apps/desktop/src/app/store.actions/provider.ts+137−0
  • apps/desktop/src/app/store.actions/thread.ts+14−0
  • apps/desktop/src/app/store.helpers.ts
+7
−0
  • apps/desktop/src/app/store.helpers/threadEventReducer/handlers/lifecycleHandlers.ts+113−1
  • apps/desktop/src/lib/filePreviewKind.ts+3−0
  • apps/desktop/src/lib/mediaProtocol.ts+51−0
  • apps/desktop/src/lib/modelChoices.ts+22−1
  • apps/desktop/src/ui/ChatView.tsx+12−2
  • apps/desktop/src/ui/chat/ComposerModelSelector.tsx+141−121
  • apps/desktop/src/ui/chat/chatLogic.ts+19−0
  • apps/desktop/src/ui/markdown/DesktopMarkdown.tsx+414−36
  • apps/desktop/src/ui/settings/pages/ManageModelsDialog.tsx+263−0
  • apps/desktop/src/ui/settings/pages/ProvidersPage.tsx+159−8
  • apps/desktop/test/chat-reasoning-ui.test.ts+41−0
  • apps/desktop/test/chat-view.stability.test.tsx+125−0
  • apps/desktop/test/composer-model-selector.test.tsx+125−0
  • apps/desktop/test/manage-models-dialog.test.tsx+258−0
  • apps/desktop/test/markdown-media.test.ts+365−0
  • apps/desktop/test/message-links.test.ts+14−0
  • apps/desktop/test/modelChoices.test.ts+34−0
  • apps/desktop/test/providers-page.test.ts+162−0
  • apps/desktop/test/workspace-settings-sync.lifecycle.test.ts+298−0
  • apps/mobile/src/app/(app)/settings/providers.tsx+34−22
  • apps/mobile/src/app/(app)/workspace/general.tsx+17−13
  • apps/mobile/src/cowork-shared/jsonrpcControlSchemas.ts+1−0
  • docs/generated/websocket-jsonrpc.d.ts+11−3
  • docs/generated/websocket-jsonrpc.schema.json+1−1
  • docs/websocket-protocol.md+8−1
  • prompts/system-models/claude-haiku-4-5.md+7−0
  • prompts/system-models/claude-opus-4-6.md+7−0
  • prompts/system-models/claude-opus-4-7.md+7−0
  • prompts/system-models/claude-opus-4-8.md+7−0
  • prompts/system-models/claude-sonnet-4-6.md+7−0
  • prompts/system-models/gemini-3-flash-preview.md+4−0
  • prompts/system-models/gemini-3.1-pro-preview.md+7−0
  • prompts/system-models/gemini-3.5-flash.md+4−0
  • prompts/system-models/gpt-5.2.md+7−0
  • prompts/system-models/gpt-5.4.md+7−0
  • prompts/system-models/gpt-5.5.md+7−0
  • prompts/system.md+7−0
  • src/config.ts+36−5
  • src/models/childModelRouting.ts+23−8
  • src/models/metadata.ts+381−22
  • src/prompt.ts+15−2
  • src/providers/connectionCatalog.ts+225−18
  • src/providers/customModels.ts+224−0
  • src/providers/index.ts+6−1
  • src/providers/modelDiscoveryCache.ts+19−0
  • src/providers/modelPreferences.ts+192−0
  • src/runtime/googleInteractionsModel.ts+15−3
  • src/runtime/openaiResponsesModel.ts+37−18
  • src/runtime/pi/modelResolution.ts+135−44
  • src/runtime/piRuntimeOptions.ts+12−5
  • src/server/agents/modelRouter.ts+47−11
  • src/server/jsonrpc/routes/provider.ts+151−1
  • src/server/jsonrpc/schema.provider.ts+15−0
  • src/server/session/AgentSession.ts+57−0
  • src/server/session/AgentSessionFromPersisted.ts+10−1
  • src/server/session/ProviderAuthManager.ts+20−4
  • src/server/session/SessionMetadataManager.ts+3−0
  • src/server/session/SessionRuntime.ts+19−0
  • src/server/session/turnExecution/userMessageAttachments.ts+3−4
  • src/shared/customModels.ts+32−0
  • src/shared/jsonrpcControlSchemas.ts+41−0
  • src/shared/modelPreferences.ts+35−0
  • src/tools/read.ts+2−2
  • src/utils/fileLock.ts+245−0
  • test/childModelRouting.test.ts+50−1
  • test/config/config.loadConfig.test.ts+86−0
  • test/fileLock.test.ts+177−0
  • test/jsonrpc.task-workspace-subscription.test.ts+28−9
  • test/jsonrpc/control.provider.test.ts+139−0
  • test/models.customModelResolution.test.ts+553−0
  • test/providers/connection-catalog.test.ts+428−6
  • test/providers/custom-models.test.ts+142−0
  • test/providers/model-discovery-cache.test.ts+24−0
  • test/providers/model-preferences.test.ts+155−0
  • test/runtime.pi-runtime.test.ts+189−0
  • test/server/agents/modelRouter.test.ts+54−0
  • test/session/agentSession.model.test.ts+53−0