Closes#174
Model identifiers like 'Qwen/Qwen3-Embedding-8B' or
'Pro/deepseek-ai/DeepSeek-V3' carry forward slashes that Spring MVC
decodes from %2F before path matching, so even with the frontend's
encodeURIComponent the request never reaches the handler and 404s out.
The two affected endpoints take modelId as a request param instead:
DELETE /{providerId}/models/{modelId} -> DELETE /{providerId}/models?modelId=...
POST /{providerId}/models/{modelId}/test -> POST /{providerId}/models/test?modelId=...
modelApi.removeProviderModel / testModel in the UI follow suit, passing
the id via axios params so axios handles the URL encoding consistently.
providerId stays as a path variable — provider ids are kebab-case and
never contain slashes.
Closes#175
ModelConfigController.testEmbedding() previously caught and stringified
the exception's getMessage() into the response body without writing
anything to the server log. Operators investigating an Embedding test
failure saw only the truncated client-side message — root causes like
the DashScope-native vs OpenAI-compat routing bug (#166) or the
requireApiKey gap (#167) were invisible server-side.
Add @Slf4j to the controller and log.error the full stack trace
alongside the failing modelId, so future Embedding test regressions are
diagnosable from the server log without redeploying with debug
breakpoints.
Closes#169
ModelConfigService.validateModel() flagged a duplicate when re-adding a
manually-typed (provider, modelName) pair that happened to match a row
with deleted=1 in mate_model_config. The user-visible symptom: adding
'dashscope/qwen3-plus' fails with 'model identifier already exists',
yet the management page shows no such model.
The project itself runs hard-delete via deleteById(), so the user-facing
delete path doesn't create deleted=1 rows. The stale rows come from
schema migrations (V44, V81) that intentionally tombstone bogus catalog
entries — for instance V81 sets deleted=1 on the non-existent
'qwen3-plus' (id=1000000172) so it stays out of routing but preserves
the id for audit. ModelConfigEntity has no @TableLogic, and the project
has no global logic-delete-field config, so LambdaQueryWrapper queries
do not auto-append the deleted filter; the migration tombstones leak
into the validate-model query.
Add an explicit .eq(getDeleted, 0) to the uniqueness check so migration
tombstones don't block legitimate re-adds.
Follow-up: several other queries in ModelConfigService share the same
oversight (list/get methods), and a future migration could drop the
tombstones entirely to align with the V20 hard-delete posture.
Closes#168
The native DashScope provider exposes both chat and embedding models, but
DASHSCOPE_NATIVE_ALLOW_PREFIXES only listed chat families
(qwen-/qwen2-/qwen3-/deepseek-/baichuan/yi-/llama). When a user manually
added text-embedding-v1/v2/v3/v4 to the dashscope provider,
assertModelIdAcceptable() rejected the id because no allow prefix matched.
Add 'text-embedding-' to the allow-list and broaden the doc comment from
"native chat protocol" to "native protocol (chat or embedding)" so the
intent is clear.
Discovery probing is chat-based and will still mark embedding entries
probeOk=false; surfacing them as discoverable embedding suggestions is a
separate follow-up.
Closes#167
EmbeddingModelFactory.buildOpenAi() hard-failed on any provider whose API
key was empty or unusable, so keyless providers like Ollama and OpenCode
(declared with requireApiKey=false) could pass the chat connectivity test
but bounce when the same provider's embedding model was tested.
Mirror the chat path in OpenAiCompatibleChatModelBuilder.buildOpenAiApi:
- If requireApiKey is not explicitly false, an unusable key still throws.
- If requireApiKey == false, the key check is skipped and an empty string
is passed to OpenAiApi.builder() so no Authorization: Bearer header is
attached to the outgoing request.
Closes#166
EmbeddingModelFactory used EmbeddingProtocol.fromProviderId() to pick the
embedding protocol, which substring-matches 'dashscope' / 'qwen' / 'aliyun'
in the providerId. The dashscope-compat provider carries 'dashscope' in its
id but runs in OpenAI compatible mode (chatModel='OpenAIChatModel',
baseUrl='https://dashscope.aliyuncs.com/compatible-mode/v1'). Routing it to
DASHSCOPE_EMBEDDING made DashScopeApi build its native path against the
compat base, producing 404s on every embedding call.
Switch to the chatModel column instead — the same signal ModelProtocol
.fromChatModel() uses for the chat path. chatModel='DashScopeChatModel'
takes the native protocol; everything else (including dashscope-compat)
takes OpenAI-compatible.
EmbeddingProtocol.fromProviderId() is retained for reference but is no
longer called; future callers should follow the chatModel pattern.
OAuth token save now promotes the first available chat model when no usable default exists. Default-model resolution and provider availability checks require Provider.enabled=true alongside credentials, so disabled Providers no longer return stale defaults. /models/enabled drops the single-Provider hard-code so OpenAI OAuth and other enabled chat models surface in selectors. Docker exposes the 1455 PKCE callback via MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST (default 127.0.0.1) and a port mapping; deployment mode stays Host-driven.
Three layers landed together because they share the same routing /
lifecycle plumbing:
1. Cron output unification
- New CronConversationResolver routes web-origin jobs to the per-workspace
tasks_<wsId> conversation; IM-bound jobs go to the channel session
conversation when one exists (matched by senderId then targetId);
legacy cron_<id> remains as the fallback.
- CronJobLifecycleService inserts a system-role header divider when a
run starts so users browsing the unified tasks_<wsId> view can tell
which job started a run. BaseAgent.sanitizeForLlm filters these
headers so they never reach the model.
- WorkspaceService seeds tasks_<wsId> on workspace creation; V65
migration backfills existing workspaces.
- DeliveryConfig gains a userId field so IM session lookup can match
by senderId (replyToken-based targetId is not stable across runs).
- ConversationVO recognizes tasks_/cron_ underscore prefix as cron
source. MessageList renders the system header as a labeled divider.
- ChatConsole pins tasks_* conversations and tracks per-conversation
read state so new cron output gets a visible unread dot.
2. Reminder task type
- New task_type='reminder' in CronJobEntity + service validation.
- CronJobRunner short-circuits 'reminder' jobs: hands trigger_message
to finishRunAndPublish verbatim, no LLM call. Fixes a regression
where reminders were rephrased into echoed wrappers.
- New create_reminder tool alongside create_cron_job, with descriptions
tightened so the model picks the right one (verbatim push vs LLM
query that needs computation).
- CronJobs.vue gets a third radio option + dedicated reminder field.
3. In-flight progress placeholder
- Cron uses non-streaming chat()/execute(); tool-heavy ReAct loops
can run 1-5 minutes between start and finish with no visible
state, looking hung.
- New GET /api/v1/cron-jobs/active-runs returns runs in status=running
for a conversation. ChatConsole polls it on the existing 4s tick
(and on conversation switch) and shows a spinner bar with elapsed
time. When run count drops to zero, it refetches messages so the
assistant bubble appears within ~1s of finish.
- Generalize the OpenAI-compatible chat/models path resolver so any
baseUrl ending in /v{N} (Ark /v3, Zhipu /v4, ...) drops the duplicate
/v1 prefix. Volcano Engine test-connection and chat were posting to
/api/v3/v1/chat/completions and getting 404.
- Replace the six pre-seeded Doubao alias rows (doubao-1.5-*) with five
valid Ark direct-call ids (doubao-seed-1-8-251228 etc.) and flip
support_model_discovery=TRUE so users can refresh their account's
actual catalog. Aliases were marketing names, not API names, so every
call hit InvalidEndpointOrModel.NotFound.
- Translate Ark business errors into actionable Chinese hints: include
the response body in the error chain, match ModelNotOpen and
InvalidEndpointOrModel codes, extract the offending model id, and
classify them as MODEL_NOT_FOUND so failover skips retries.
Adds explicit user-intent gating to the provider catalog. Fresh installs
get an empty dropdown by default — built-in cloud + local providers
(OpenAI, Anthropic, Ollama, LM Studio, MLX, llama.cpp, etc.) live in a
new 'Add Provider' drawer until the user opts them in. Existing installs
upgrade conservatively: V55 promotes any provider with evidence of use
(real api_key, OAuth token, recent chat usage, or current default model).
Backend
- V55 migration (H2 + MySQL): adds enabled BOOLEAN DEFAULT FALSE on
mate_model_provider, plus 4 promote-to-true UPDATE rules. Also
CREATE INDEX idx_message_runtime_provider_time so the 30-day usage
lookup doesn't full-scan mate_message on heavy users.
- ModelProviderEntity, ProviderInfoDTO: enabled field.
- ModelProviderService:
* listProviders() now filters WHERE enabled = TRUE — chat path,
ModelSelector, Settings/Models main grid see only opted-in rows.
* listCatalog() new — full catalog (enabled + disabled) for the drawer.
* setEnabled(id, enabled) flips the flag, publishes
ModelConfigChangedEvent (re-probe via the existing listener), and
on disable auto-promotes a replacement default model when the
disabled provider owned the current default. Returns EnableResult
so the frontend can fire a toast.
* createCustomProvider sets enabled=true (user just made the row).
- ProviderInitProbe.listConfiguredProviders also filters enabled=true —
no point probing rows the user can't see.
- ModelConfigController: GET /catalog, POST /{id}/enable, POST /{id}/disable.
- Plugin-registered ChatModels are unaffected — they live in
pluginChatModels (in-memory map), don't go through DB listProviders,
so the enabled filter doesn't strand them.
Frontend
- New types: ProviderInfo.enabled, EnableResult.
- New API: catalog / enableProvider / disableProvider.
- New composable useProviderEnablement: catalog ref, drawerOpen,
togglingId, loadCatalog, openDrawer / closeDrawer, enableProvider,
disableProvider (fires defaultSwitchedToast on auto-switch).
- AddProviderDrawer.vue: lazy-loaded, reuses DoctorDrawer's Teleport +
overlay + slide-in panel pattern. Two groups (cloud / local),
unenabled rows surface to the top of each group, enabled rows show
an 'Enabled' badge instead of a button. Mobile: full-screen sheet
that slides up from below.
- ProviderCard: new 'Disable' button with soft-danger styling on
enabled providers — soft-hide that keeps the config; user can
re-enable from the drawer.
- Settings/Models index.vue:
* Two top CTAs: 'Enable Provider' (drawer) and 'Custom' (existing
custom-create modal) — distinct workflows, both surfaced.
* Empty state with prominent 'Enable Provider' CTA when zero
enabled providers — paired with onMounted auto-open of the
drawer (sessionStorage guard so closing it doesn't bring it
back on the next route visit in the same session).
* Deep-link: ?addProvider=1 query forces the drawer open and
strips itself after, so a back/forward doesn't re-fire the open.
- ModelSelector: when groups.length === 0 and not searching, show
'No providers configured -> Configure' CTA linking to
/settings/models?addProvider=1 — the natural flow when a fresh
user opens chat before configuring anything.
- i18n: 13 new keys per locale (zh-CN + en-US) plus common.close.
Migration safety
- Conservative default policy: only rows with concrete evidence of
use are auto-enabled; everything else stays hidden. Upgrade users
may notice unused built-ins disappearing from their dropdown —
that's the intended cleanup.
- mate_message index added so the 30-day usage rule doesn't full-scan
on large installations; FlywayRepairConfig handles redeploy idempotency.
Tests
- ModelProviderServiceEnableTest covers all 7 enable/disable branches:
flag flip + event publish, no-op on already-{enabled,disabled},
default-switch when disabled provider owned current default,
no-switch when default belongs elsewhere, no-replacement returns
unchanged, getDefaultModel exception path, candidates with no
models are skipped.
- ProviderInitProbeTest: helper provider() now sets enabled=true so
the new probe filter doesn't strand existing fixtures.
- vip.mate.llm.** suite: 125 tests green. vue-tsc 0 errors. Browser
page renders with both new buttons + drawer.
Phase 1 of the model-module refactor: combine pool / cooldown / probe-
completion signals into a single Liveness state surfaced through the
provider DTO, so the dropdown stops listing providers that are provably
unreachable. Zero schema change; one PR backend + frontend.
Backend
- Liveness enum with five mutually-exclusive states: LIVE, COOLDOWN,
REMOVED, UNPROBED, UNCONFIGURED. Computed in ModelProviderService
from AvailableProviderPool / ProviderHealthTracker / ProviderInitProbe
snapshots batched once per listProviders() call.
- ProviderInitProbe.hasBeenProbed exposes a monotonic Set so the UI
can distinguish 'still booting' from 'probed and removed' — without
it the startup window flashes false REMOVED states.
- ProviderInfoDTO gains liveness + unavailableReason +
cooldownRemainingMs + lastProbedAtMs. The legacy 'available' boolean
stays but is now derived from liveness == LIVE so the chat fallback
walker and the dropdown agree about what's usable.
- ProviderInitProbe injected into ModelProviderService via
ObjectProvider to break the startup cycle (probe already depends on
the service).
Frontend
- ProviderInfo type extended with liveness + the three detail fields.
- ModelSelector filters UNCONFIGURED + REMOVED out of the dropdown,
shows COOLDOWN / UNPROBED with a status dot and dimmed rows that the
user can still click to override.
- ProviderCard renders a five-state badge driven by liveness instead
of the old configured + pool-entry combo. Reprobe button now keys
off liveness in {REMOVED, COOLDOWN}.
- useProviders drops loadProviderPool / providerPool — pool data ships
inline on each ProviderInfo, saves a round trip per page load and
keeps a single source of truth.
- i18n: 8 new keys across zh-CN and en-US for liveness labels and the
cooldown countdown tooltips.
Bonus fix (discovered during verification): AgentGraphBuilder.buildOpenAiApi
hard-required a usable API key on every OpenAI-compat provider, ignoring
the per-provider requireApiKey flag. That bug stranded keyless local
runtimes (LM Studio / MLX / llama.cpp) the moment a user actually
launched them; Ollama only worked by accident because its seed row
carries a placeholder string in api_key. keyRequired now honors
requireApiKey, and Spring AI's NoopApiKey is used when no key is needed
so the Authorization header is omitted entirely.
Test
- ModelProviderServiceLivenessTest covers all five Liveness states +
the probe-bean-absent fallback branch.
- vip.mate.llm.** suite (118 tests) green; vue-tsc clean.
- End-to-end browser sanity: 27 raw providers reduce to 6 LIVE groups
in the chat dropdown; LM Studio / MLX / llama.cpp render REMOVED red
badges with reprobe buttons; cloud providers without keys show
UNCONFIGURED.
Two real bugs the user restart surfaced — both turned healthy providers
into HARD-removed false positives.
Bug #1 — URL duplication
OpenAiCompatibleListModelsProbe always concatenated /v1/models, so
providers whose Base URL already includes the version segment got the
wrong URL:
LMStudio http://localhost:1234/v1 → /v1/v1/models → 404
ZhipuAI .../api/paas/v4 → /v4/v1/models → 404
Fix: detect a trailing /vN suffix and append /models instead. Six unit
tests in OpenAiCompatibleListModelsProbeTest lock the rule down.
Bug #2 — 404 false positives
Kimi for Coding API does not expose /v1/models even though chat works
fine, so the probe correctly received a 404 and incorrectly HARD-removed
the provider from the pool. Other vendors will hit the same — listing
is not a universal contract.
Fix: classify HTTP responses semantically.
401 / 403 → HARD remove (real auth failure)
404 / 405 / 410 → fail-open (endpoint missing, server may be alive)
other 4xx / 5xx → fail-open (probe inconclusive — let chat decide)
network errors → fail (unreachable)
This is the same philosophy as ChatGPTOAuthStatusProbe: when we cannot
cheaply confirm health, we do not proactively penalize the provider.
Same logic applied to Anthropic + DashScope probes for consistency.
Net effect on the user deployment after restart:
- kimi-code stays in pool (404 → fail-open) → primary path works again
- lmstudio + zhipu-cn also stay in pool (URL bug fixed)
- dashscope + ollama unchanged (real 200 OK)
Tests: 6 new for resolveModelsPath. The 2 unrelated WikiRawMaterialDedupTest
failures pre-date this commit and live in ba86bea.