Canonicalize assistant provider registry

Add a registry-backed provider model for runtime metadata and chat-compatible transports.

Support Z.ai, Groq, Mistral, Cerebras, Together, and Fireworks through the shared chat-compatible client path while keeping native providers on their existing adapters. Expose provider metadata through settings and render the expanded provider set in the frontend.
This commit is contained in:
rcourtman 2026-06-23 19:38:22 +01:00
parent da0693b3b1
commit 09fc029bd6
30 changed files with 1633 additions and 224 deletions

View file

@ -229,7 +229,7 @@ the live set.
- `get_resource_context` (Get resource context, `GET /api/agent/resource-context/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the situated picture of a resource — identity, operator-set state with maintenance-window-active flag, active findings, pending approvals scoped to this resource, recent actions including refused dispatches. Command fields are redacted for monitoring-read API tokens unless the token also has ai:execute.
- `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth.
- `get_operations_loop_status` (Get Patrol work status, `GET /api/agent/operations-loop/status`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the current content-safe Patrol work status: Patrol issue evidence, pending approvals, governed decisions/actions, verified outcomes, Patrol control outcome evidence, compatibility aliases, and optional MCP readiness. The payload is count-only and deliberately omits finding ids, action ids, prompts, commands, resource names, and output.
- `get_patrol_control_status` (Get Patrol work status, `GET /api/agent/patrol-control/status`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the current content-safe Patrol work status: Patrol issue evidence, pending approvals, governed decisions/actions, verified outcomes, Patrol control outcome evidence, compatibility aliases, and optional MCP readiness. The payload is count-only and deliberately omits finding ids, action ids, prompts, commands, resource names, and output.
**Operator state (per-resource intent):**

View file

@ -623,6 +623,12 @@ instead of lifecycle-local centered icon/text shells.
they must not treat manifest `workflowPrompts`, rendered prompt bodies, or
Assistant starter availability as install, enrollment, or fleet-control
route grammar.
AI provider registry and `/api/settings/ai` credential-shape changes in
`internal/api/ai_handlers.go` are likewise adjacent AI-runtime/API-contract
ownership. Agent lifecycle may depend on the shared authenticated API
substrate staying intact, but provider ids, provider default endpoints,
model routes, API-key fields, and provider configured state are not agent
install, enrollment, reporting, or fleet-control lifecycle inputs.
The adjacent workflow-starter activity markers on
`POST /api/ai/workflow-prompts/activity` and
`POST /api/agent/workflow-prompt-activity` follow that same split:

View file

@ -106,6 +106,14 @@ they may decide which manifest-owned prompt is contextually usable, but display
labels and rendered prompt text must come from shared manifest metadata and the
shared backend renderer on
`POST /api/ai/workflow-prompts/render`.
AI provider integration is registry-owned. Provider identity, display names,
protocol family, default model, default base URL, credential fields,
configured-state fields, clear-key fields, env-var hints, docs links, and
fallback catalog models belong in the config-layer provider registry, not in
factory branches, settings-card literals, or per-provider model-list code. New
direct chat-compatible providers extend that registry and the shared
chat-compatible provider client; native transports remain explicit only where
the protocol is not chat-compatible.
## Canonical Files
@ -115,6 +123,7 @@ shared backend renderer on
1c. `internal/agentcapabilities/`
1d. `scripts/generate-pulse-intelligence-docs.go`
2. `internal/config/ai.go`
2a. `internal/config/ai_providers.go`
3. `internal/api/ai_handler.go`
4. `internal/api/ai_handlers.go`
5. `internal/api/ai_hosted_runtime.go`
@ -1008,7 +1017,18 @@ but status selection must read the current workflow status directly rather than
deriving an older display status from `workflowStatusHistory`.
1. Add or change chat runtime, Patrol orchestration, findings generation, or remediation behavior through `internal/ai/`
2. Add or change canonical AI provider config, provider-scoped model selection, or runtime auth/base-URL defaults through `internal/config/ai.go`.
2. Add or change canonical AI provider config, provider registry metadata, provider-scoped model selection, or runtime auth/base-URL defaults through `internal/config/ai.go` and `internal/config/ai_providers.go`.
Direct providers that speak the shared chat-compatible API must use the
registry-backed `AIProviderProtocolOpenAICompatible` path and
`NewOpenAICompatibleClient` rather than adding new provider-specific factory
branches. Per-provider auth fields, configured booleans, clear-key fields,
default base URLs, display names, docs links, env-var hints, and fallback
model rows must be declared once in `internal/config/ai_providers.go` and
projected to `/api/settings/ai`; frontend settings may arrange those
provider cards, but must not become the source of truth for provider
capability or credential metadata. Provider model-cache keys must include
provider identity, auth/base-URL inputs, and credential identity changes
without exposing raw secret values.
Ollama request keep-alive is a provider runtime option owned by this path:
`internal/config/ai.go` stores the value, `/api/settings/ai` exposes it,
and `internal/ai/providers/ollama.go` is the only layer that turns it into

View file

@ -788,6 +788,18 @@ payload shape change when the portal presents compact client rows.
3. `frontend-modern/src/api/agentCapabilities.ts` shared with `ai-runtime`: the agent capabilities frontend client is both the Pulse Intelligence external-agent manifest consumer and a canonical API payload contract boundary.
4. `frontend-modern/src/api/agentProfiles.ts` shared with `agent-lifecycle`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary.
5. `frontend-modern/src/api/ai.ts` shared with `ai-runtime`: the AI frontend client is both an AI runtime control surface and a canonical API payload contract boundary.
`/api/settings/ai` is the provider-registry projection for Assistant and
Patrol runtime configuration. Its `providers` collection exposes
non-secret provider metadata from the config-layer registry, including
provider id, display name, protocol family, default route, default base URL,
credential field names, configured-state field names, clear-key field names,
env-var hints, docs URL, gateway flag, and current configured state. The
legacy top-level configured booleans may remain for browser compatibility,
but new provider additions must update the registry-backed projection and
contract tests rather than adding an untyped browser-only provider list.
API responses must never echo provider secret values; settings updates may
accept credential and clear-key fields, persist trimmed values, and return
only configured state.
6. `frontend-modern/src/api/aiChat.ts` shared with `ai-runtime`: the Assistant chat frontend client is both the first-party Assistant transport surface and a canonical API payload contract boundary.
7. `frontend-modern/src/api/generated/agentCapabilities.ts` shared with `ai-runtime`: the generated agent capabilities frontend types are both the Pulse Intelligence manifest TypeScript projection and a canonical API payload contract boundary.
8. `frontend-modern/src/api/nodes.ts` shared with `agent-lifecycle`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary.

View file

@ -1423,7 +1423,14 @@ settings`, and candidate review when discovered sources are waiting. It must
remediation by parsing raw upstream error strings in the browser. Provider
setup cards must describe provider families through the current
backend-owned provider contract; DeepSeek setup copy is the V4 family and
must not regress to old V3 or compatibility-alias wording.
must not regress to old V3 or compatibility-alias wording. First-class
provider cards on `Provider & Models` must remain model-driven through
`AI_PROVIDERS`, `AI_PROVIDER_CONFIGS`, and the `useAISettingsState`
provider payload mapping: adding direct chat-compatible providers such as
Z.ai, Groq, Mistral, Cerebras, Together, or Fireworks extends those shared
arrays/maps and the backend registry projection instead of introducing
provider-specific JSX branches, local configured-state inference, or
browser-owned default endpoint facts.
Provider connection controls are page-scoped: the global Pulse
Intelligence enable toggle, provider readiness strip, and Test Connection
action belong to `Provider & Models`; Patrol, Assistant, and Service Context

View file

@ -394,7 +394,12 @@ recovery scope, or a storage/recovery-owned secret source.
resulting Assistant context if exposed by another surface, but must not
treat resource-to-action-target coercion as recovery scope, backup
ownership, restore authorization, or storage-provider identity.
freshness, restore capability, or storage-provider health.
AI provider registry and `/api/settings/ai` credential-shape changes in
`internal/api/ai_handlers.go` are adjacent runtime configuration only:
provider ids, default model routes, provider endpoints, API-key fields, and
configured-state fields must not become backup-source identity, recovery
target identity, restore authorization, storage-provider health, or
recovery-point metadata.
Shared API-token transport helpers may be consumed by storage/recovery-
adjacent flows, but `owner_user_id` remains server-authored token identity
metadata; storage/recovery extensions must not pass metadata that authors

View file

@ -65,7 +65,7 @@ All other documents are supporting references unless explicitly required for evi
## 📖 Advanced Topics (Relay / Pro / legacy Pro+ / Cloud)
- **[AI Autonomy & Safety](AI_AUTONOMY.md)** – Configure patrol autonomy levels, assistant control levels, investigation tuning, and safety guardrails.
- **[AI Modes & Safety](AI_AUTONOMY.md)** – Configure Patrol mode, assistant control levels, investigation tuning, and safety guardrails.
- **[Role-Based Access Control (RBAC)](RBAC.md)** – Define custom roles, assign permissions, and integrate with OIDC group mapping.
- **[Audit Logging](AUDIT_LOGGING.md)** – Tamper-evident event logging for compliance, with query, export, and signature verification.
@ -77,7 +77,7 @@ All other documents are supporting references unless explicitly required for evi
- **[Relay / Pulse Mobile Handoff](RELAY.md)** – End-to-end encrypted relay for supported Pulse Mobile clients (Relay and above).
- **[Recovery Central](RECOVERY.md)** – Unified backup, snapshot, and replication view across all providers.
- **[Pulse Cloud (Hosted)](CLOUD.md)** – Fully managed hosting with automatic updates and backups.
- **[Pulse AI](AI.md)** – Chat assistant, patrol findings, alert analysis, intelligence, and forecasts.
- **[Pulse Intelligence](AI.md)** – Pulse Assistant, Patrol findings, alert analysis, governed actions, and forecasts.
- **[Metrics History](METRICS_HISTORY.md)** – Persistent metrics storage with configurable retention.
- **[Mail Gateway](MAIL_GATEWAY.md)** – Proxmox Mail Gateway (PMG) monitoring.
- **[Auto Updates](AUTO_UPDATE.md)** – One-click updates for supported deployments.
@ -91,7 +91,7 @@ Pulse is available in three self-hosted tiers plus hosted Cloud:
- **Community**: Free self-hosted monitoring with core monitoring included and 7-day history.
- **Relay**: Adds secure remote access to the Pulse web UI, Pulse Mobile pairing for handoff, push notifications, and 14-day history.
- **Pro**: Adds alert-triggered root-cause analysis, safe remediation workflows, operations tooling, governance features, and 90-day history.
- **Pro**: Adds hands-on Patrol modes, issue investigation, governed fixes, verified outcomes, operations tooling, governance features, and 90-day history.
- **Cloud**: Hosted Pulse with Pro-level capabilities; hosted pricing is unchanged by the self-hosted model lock.
- **[Learn more at pulserelay.pro](https://pulserelay.pro)**

View file

@ -224,7 +224,9 @@ describe('AISettings model loading error states', () => {
expect(screen.getByRole('button', { name: /Save Assistant settings/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'External agents' })).toBeInTheDocument();
expect(
screen.getByText('Optional connector access for Claude Desktop', { exact: false }),
screen.getByText('Connect external tools to read Pulse context and request Patrol work.', {
exact: false,
}),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Show connector setup' })).toBeInTheDocument();
expect(

View file

@ -663,6 +663,13 @@ describe('settings architecture guardrails', () => {
});
it('keeps Assistant and Patrol provider-specific fields model-driven', () => {
expect(aiSettingsModelSource).toContain("export const AI_PROVIDERS: AIProvider[] = [");
for (const provider of ['zai', 'groq', 'mistral', 'cerebras', 'together', 'fireworks']) {
expect(aiSettingsModelSource).toContain(`provider: '${provider}'`);
expect(aiSettingsStateSource).toContain(`${provider}_api_key`);
expect(aiSettingsStateSource).toContain(`clear_${provider}_key`);
expect(aiSettingsStateSource).toContain(`${provider}_configured`);
}
expect(aiSettingsModelSource).toContain('extraFields: [');
expect(aiSettingsModelSource).toContain("inputField: 'ollamaKeepAlive'");
expect(aiSettingsModelSource).toContain("helpContentId: 'ai.ollama.keepAlive'");

View file

@ -13,6 +13,12 @@ export type AIProviderCredentialsFormState = {
openaiApiKey: string;
openrouterApiKey: string;
deepseekApiKey: string;
zaiApiKey: string;
groqApiKey: string;
mistralApiKey: string;
cerebrasApiKey: string;
togetherApiKey: string;
fireworksApiKey: string;
geminiApiKey: string;
ollamaBaseUrl: string;
ollamaKeepAlive: string;
@ -62,6 +68,12 @@ export const AI_PROVIDERS: AIProvider[] = [
'openrouter',
'deepseek',
'gemini',
'zai',
'groq',
'mistral',
'cerebras',
'together',
'fireworks',
'ollama',
];
@ -71,6 +83,12 @@ export const AI_SETUP_PROVIDER_OPTIONS: SelectionCardOption<AIProvider>[] = [
{ value: 'openrouter', title: 'OpenRouter', description: 'Gateway' },
{ value: 'deepseek', title: 'DeepSeek', description: 'V4' },
{ value: 'gemini', title: 'Gemini', description: 'Google' },
{ value: 'zai', title: 'Z.ai', description: 'GLM' },
{ value: 'groq', title: 'Groq', description: 'Fast' },
{ value: 'mistral', title: 'Mistral', description: 'Models' },
{ value: 'cerebras', title: 'Cerebras', description: 'Inference' },
{ value: 'together', title: 'Together', description: 'Open models' },
{ value: 'fireworks', title: 'Fireworks', description: 'Open models' },
{ value: 'ollama', title: 'Ollama', description: 'Local' },
];
@ -133,6 +151,84 @@ export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
actionLinkHref: 'https://platform.deepseek.com/api_keys',
clearTitle: 'Clear API key',
},
{
provider: 'zai',
title: 'Z.ai',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'zaiApiKey',
placeholder: 'Z.AI API key',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Open docs →',
actionLinkHref: 'https://docs.z.ai/guides/develop/openai/python',
helperText: 'Uses https://api.z.ai/api/paas/v4 automatically.',
clearTitle: 'Clear API key',
},
{
provider: 'groq',
title: 'Groq',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'groqApiKey',
placeholder: 'gsk_...',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://console.groq.com/keys',
helperText: 'Uses https://api.groq.com/openai/v1 automatically.',
clearTitle: 'Clear API key',
},
{
provider: 'mistral',
title: 'Mistral',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'mistralApiKey',
placeholder: 'Mistral API key',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://console.mistral.ai/api-keys',
helperText: 'Uses https://api.mistral.ai/v1 automatically.',
clearTitle: 'Clear API key',
},
{
provider: 'cerebras',
title: 'Cerebras',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'cerebrasApiKey',
placeholder: 'Cerebras API key',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Open docs →',
actionLinkHref: 'https://inference-docs.cerebras.ai/resources/openai',
helperText: 'Uses https://api.cerebras.ai/v1 automatically.',
clearTitle: 'Clear API key',
},
{
provider: 'together',
title: 'Together AI',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'togetherApiKey',
placeholder: 'Together API key',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://api.together.ai/settings/api-keys',
helperText: 'Uses https://api.together.xyz/v1 automatically.',
clearTitle: 'Clear API key',
},
{
provider: 'fireworks',
title: 'Fireworks AI',
configuredLabel: 'Configured',
inputType: 'password',
inputField: 'fireworksApiKey',
placeholder: 'Fireworks API key',
configuredPlaceholder: '••••••••••• (configured)',
actionLinkLabel: 'Get API key →',
actionLinkHref: 'https://fireworks.ai/account/api-keys',
helperText: 'Uses https://api.fireworks.ai/inference/v1 automatically.',
clearTitle: 'Clear API key',
},
{
provider: 'gemini',
title: 'Google Gemini',
@ -175,6 +271,12 @@ export const createInitialProviderHealth = (): Record<AIProvider, ProviderHealth
openrouter: { status: 'not_configured', message: '' },
deepseek: { status: 'not_configured', message: '' },
gemini: { status: 'not_configured', message: '' },
zai: { status: 'not_configured', message: '' },
groq: { status: 'not_configured', message: '' },
mistral: { status: 'not_configured', message: '' },
cerebras: { status: 'not_configured', message: '' },
together: { status: 'not_configured', message: '' },
fireworks: { status: 'not_configured', message: '' },
ollama: { status: 'not_configured', message: '' },
});
@ -202,6 +304,18 @@ export function isAIProviderConfigured(
return settings.deepseek_configured;
case 'gemini':
return settings.gemini_configured;
case 'zai':
return Boolean(settings.zai_configured);
case 'groq':
return Boolean(settings.groq_configured);
case 'mistral':
return Boolean(settings.mistral_configured);
case 'cerebras':
return Boolean(settings.cerebras_configured);
case 'together':
return Boolean(settings.together_configured);
case 'fireworks':
return Boolean(settings.fireworks_configured);
case 'ollama':
return settings.ollama_configured;
default:

View file

@ -54,6 +54,12 @@ const AI_SETTINGS_PROVIDER_PAYLOAD_FIELDS: Record<AIProvider, string[]> = {
openai: ['openai_api_key', 'openai_base_url'],
openrouter: ['openrouter_api_key'],
deepseek: ['deepseek_api_key'],
zai: ['zai_api_key'],
groq: ['groq_api_key'],
mistral: ['mistral_api_key'],
cerebras: ['cerebras_api_key'],
together: ['together_api_key'],
fireworks: ['fireworks_api_key'],
gemini: ['gemini_api_key'],
ollama: ['ollama_base_url', 'ollama_keep_alive'],
};
@ -306,6 +312,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
openaiApiKey: '',
openrouterApiKey: '',
deepseekApiKey: '',
zaiApiKey: '',
groqApiKey: '',
mistralApiKey: '',
cerebrasApiKey: '',
togetherApiKey: '',
fireworksApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: 'http://localhost:11434',
ollamaKeepAlive: '30s',
@ -345,6 +357,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
current.openai_configured ||
current.openrouter_configured ||
current.deepseek_configured ||
current.zai_configured ||
current.groq_configured ||
current.mistral_configured ||
current.cerebras_configured ||
current.together_configured ||
current.fireworks_configured ||
current.gemini_configured ||
current.ollama_configured),
);
@ -385,6 +403,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
openaiApiKey: '',
openrouterApiKey: '',
deepseekApiKey: '',
zaiApiKey: '',
groqApiKey: '',
mistralApiKey: '',
cerebrasApiKey: '',
togetherApiKey: '',
fireworksApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: 'http://localhost:11434',
ollamaKeepAlive: '30s',
@ -421,6 +445,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
openaiApiKey: '',
openrouterApiKey: '',
deepseekApiKey: '',
zaiApiKey: '',
groqApiKey: '',
mistralApiKey: '',
cerebrasApiKey: '',
togetherApiKey: '',
fireworksApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
ollamaKeepAlive: data.ollama_keep_alive ?? '30s',
@ -442,6 +472,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
if (data.openrouter_configured) configured.add('openrouter');
if (data.deepseek_configured) configured.add('deepseek');
if (data.gemini_configured) configured.add('gemini');
if (data.zai_configured) configured.add('zai');
if (data.groq_configured) configured.add('groq');
if (data.mistral_configured) configured.add('mistral');
if (data.cerebras_configured) configured.add('cerebras');
if (data.together_configured) configured.add('together');
if (data.fireworks_configured) configured.add('fireworks');
if (data.ollama_configured) configured.add('ollama');
if (configured.size === 0) configured.add('anthropic');
setExpandedProviders(configured);
@ -702,6 +738,42 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
return;
}
payload.deepseek_api_key = setupApiKey().trim();
} else if (setupProvider() === 'zai') {
if (!setupApiKey().trim()) {
notificationStore.error('Please enter your Z.ai API key');
return;
}
payload.zai_api_key = setupApiKey().trim();
} else if (setupProvider() === 'groq') {
if (!setupApiKey().trim()) {
notificationStore.error('Please enter your Groq API key');
return;
}
payload.groq_api_key = setupApiKey().trim();
} else if (setupProvider() === 'mistral') {
if (!setupApiKey().trim()) {
notificationStore.error('Please enter your Mistral API key');
return;
}
payload.mistral_api_key = setupApiKey().trim();
} else if (setupProvider() === 'cerebras') {
if (!setupApiKey().trim()) {
notificationStore.error('Please enter your Cerebras API key');
return;
}
payload.cerebras_api_key = setupApiKey().trim();
} else if (setupProvider() === 'together') {
if (!setupApiKey().trim()) {
notificationStore.error('Please enter your Together AI API key');
return;
}
payload.together_api_key = setupApiKey().trim();
} else if (setupProvider() === 'fireworks') {
if (!setupApiKey().trim()) {
notificationStore.error('Please enter your Fireworks AI API key');
return;
}
payload.fireworks_api_key = setupApiKey().trim();
} else if (setupProvider() === 'gemini') {
if (!setupApiKey().trim()) {
notificationStore.error('Please enter your Google Gemini API key');
@ -796,6 +868,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
(modelProvider === 'openai' && form.openaiApiKey.trim()) ||
(modelProvider === 'openrouter' && form.openrouterApiKey.trim()) ||
(modelProvider === 'deepseek' && form.deepseekApiKey.trim()) ||
(modelProvider === 'zai' && form.zaiApiKey.trim()) ||
(modelProvider === 'groq' && form.groqApiKey.trim()) ||
(modelProvider === 'mistral' && form.mistralApiKey.trim()) ||
(modelProvider === 'cerebras' && form.cerebrasApiKey.trim()) ||
(modelProvider === 'together' && form.togetherApiKey.trim()) ||
(modelProvider === 'fireworks' && form.fireworksApiKey.trim()) ||
(modelProvider === 'gemini' && form.geminiApiKey.trim()) ||
(modelProvider === 'ollama' && form.ollamaBaseUrl.trim());
@ -869,6 +947,24 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
if (form.deepseekApiKey.trim()) {
payload.deepseek_api_key = form.deepseekApiKey.trim();
}
if (form.zaiApiKey.trim()) {
payload.zai_api_key = form.zaiApiKey.trim();
}
if (form.groqApiKey.trim()) {
payload.groq_api_key = form.groqApiKey.trim();
}
if (form.mistralApiKey.trim()) {
payload.mistral_api_key = form.mistralApiKey.trim();
}
if (form.cerebrasApiKey.trim()) {
payload.cerebras_api_key = form.cerebrasApiKey.trim();
}
if (form.togetherApiKey.trim()) {
payload.together_api_key = form.togetherApiKey.trim();
}
if (form.fireworksApiKey.trim()) {
payload.fireworks_api_key = form.fireworksApiKey.trim();
}
if (form.geminiApiKey.trim()) {
payload.gemini_api_key = form.geminiApiKey.trim();
}
@ -1027,6 +1123,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
current?.openai_configured,
current?.openrouter_configured,
current?.deepseek_configured,
current?.zai_configured,
current?.groq_configured,
current?.mistral_configured,
current?.cerebras_configured,
current?.together_configured,
current?.fireworks_configured,
current?.gemini_configured,
current?.ollama_configured,
].filter(Boolean).length;
@ -1055,6 +1157,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
if (provider === 'openai') clearPayload.clear_openai_key = true;
if (provider === 'openrouter') clearPayload.clear_openrouter_key = true;
if (provider === 'deepseek') clearPayload.clear_deepseek_key = true;
if (provider === 'zai') clearPayload.clear_zai_key = true;
if (provider === 'groq') clearPayload.clear_groq_key = true;
if (provider === 'mistral') clearPayload.clear_mistral_key = true;
if (provider === 'cerebras') clearPayload.clear_cerebras_key = true;
if (provider === 'together') clearPayload.clear_together_key = true;
if (provider === 'fireworks') clearPayload.clear_fireworks_key = true;
if (provider === 'gemini') clearPayload.clear_gemini_key = true;
if (provider === 'ollama') clearPayload.clear_ollama_url = true;
@ -1068,6 +1176,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
if (provider === 'openai') setForm('openaiApiKey', '');
if (provider === 'openrouter') setForm('openrouterApiKey', '');
if (provider === 'deepseek') setForm('deepseekApiKey', '');
if (provider === 'zai') setForm('zaiApiKey', '');
if (provider === 'groq') setForm('groqApiKey', '');
if (provider === 'mistral') setForm('mistralApiKey', '');
if (provider === 'cerebras') setForm('cerebrasApiKey', '');
if (provider === 'together') setForm('togetherApiKey', '');
if (provider === 'fireworks') setForm('fireworksApiKey', '');
if (provider === 'gemini') setForm('geminiApiKey', '');
if (provider === 'ollama') setForm('ollamaBaseUrl', '');

View file

@ -1,9 +1,41 @@
// AI feature types
export type AIProvider = 'anthropic' | 'openai' | 'openrouter' | 'ollama' | 'deepseek' | 'gemini';
export type AIProvider =
| 'anthropic'
| 'openai'
| 'openrouter'
| 'ollama'
| 'deepseek'
| 'gemini'
| 'zai'
| 'groq'
| 'mistral'
| 'cerebras'
| 'together'
| 'fireworks';
export type AuthMethod = 'api_key' | 'oauth';
export type PatrolReadinessStatus = 'ready' | 'warning' | 'not_ready';
export interface AIProviderDefinition {
id: AIProvider | string;
display_name: string;
description: string;
protocol: 'anthropic' | 'openai_compatible' | 'gemini' | 'ollama' | 'retired' | string;
default_model?: string;
default_base_url?: string;
api_key_field?: string;
configured_field?: string;
clear_key_field?: string;
base_url_field?: string;
requires_api_key: boolean;
user_configurable: boolean;
gateway: boolean;
configured: boolean;
models_dev_provider_id?: string;
env_vars: string[];
docs_url?: string;
}
export interface PatrolReadinessCheck {
id: string;
status: PatrolReadinessStatus;
@ -62,11 +94,18 @@ export interface AISettings {
openrouter_configured: boolean; // true if OpenRouter API key is set
deepseek_configured: boolean; // true if DeepSeek API key is set
gemini_configured: boolean; // true if Gemini API key is set
zai_configured?: boolean; // true if Z.ai (Zhipu) API key is set
groq_configured?: boolean; // true if Groq API key is set
mistral_configured?: boolean; // true if Mistral API key is set
cerebras_configured?: boolean; // true if Cerebras API key is set
together_configured?: boolean; // true if Together AI API key is set
fireworks_configured?: boolean; // true if Fireworks AI API key is set
ollama_configured: boolean; // true (always available for attempt)
ollama_base_url: string; // Ollama server URL
ollama_keep_alive: string; // Ollama keep_alive value; empty uses the server default
openai_base_url?: string; // Custom OpenAI base URL
configured_providers: AIProvider[]; // List of providers with credentials
providers?: AIProviderDefinition[]; // Server-authored provider registry metadata
// Cost controls (30-day budget, pro-rated in UI)
cost_budget_usd_30d?: number;
@ -131,6 +170,12 @@ export interface AISettingsUpdateRequest {
openrouter_api_key?: string; // Set OpenRouter API key
deepseek_api_key?: string; // Set DeepSeek API key
gemini_api_key?: string; // Set Gemini API key
zai_api_key?: string; // Set Z.ai (Zhipu) API key
groq_api_key?: string; // Set Groq API key
mistral_api_key?: string; // Set Mistral API key
cerebras_api_key?: string; // Set Cerebras API key
together_api_key?: string; // Set Together AI API key
fireworks_api_key?: string; // Set Fireworks AI API key
ollama_base_url?: string; // Set Ollama server URL
ollama_keep_alive?: string; // Set Ollama keep_alive; empty uses the server default
openai_base_url?: string; // Set custom OpenAI base URL
@ -140,6 +185,12 @@ export interface AISettingsUpdateRequest {
clear_openrouter_key?: boolean; // Clear OpenRouter API key
clear_deepseek_key?: boolean; // Clear DeepSeek API key
clear_gemini_key?: boolean; // Clear Gemini API key
clear_zai_key?: boolean; // Clear Z.ai API key
clear_groq_key?: boolean; // Clear Groq API key
clear_mistral_key?: boolean; // Clear Mistral API key
clear_cerebras_key?: boolean; // Clear Cerebras API key
clear_together_key?: boolean; // Clear Together AI API key
clear_fireworks_key?: boolean; // Clear Fireworks AI API key
clear_ollama_url?: boolean; // Clear Ollama URL
// Cost controls
@ -186,6 +237,12 @@ export const PROVIDER_DESCRIPTIONS: Record<AIProvider, string> = {
ollama: 'Local models via Ollama',
deepseek: 'DeepSeek V4 models',
gemini: 'Gemini models from Google',
zai: 'GLM models from Z.ai',
groq: 'Hosted models from Groq',
mistral: 'Mistral models',
cerebras: 'Cerebras Inference models',
together: 'Together AI hosted models',
fireworks: 'Fireworks AI hosted models',
};
// Conversation history for multi-turn chats

View file

@ -12,6 +12,12 @@ describe('aiProviderPresentation', () => {
expect(AI_PROVIDER_DISPLAY_NAMES.openai).toBe('OpenAI');
expect(getAIProviderDisplayName('anthropic')).toBe('Anthropic');
expect(getAIProviderDisplayName('gemini')).toBe('Google Gemini');
expect(getAIProviderDisplayName('zai')).toBe('Z.ai');
expect(getAIProviderDisplayName('groq')).toBe('Groq');
expect(getAIProviderDisplayName('mistral')).toBe('Mistral');
expect(getAIProviderDisplayName('cerebras')).toBe('Cerebras');
expect(getAIProviderDisplayName('together')).toBe('Together AI');
expect(getAIProviderDisplayName('fireworks')).toBe('Fireworks AI');
expect(getAIProviderDisplayName('pulse')).toBe('Pulse');
expect(getAIProviderDisplayName('custom-provider')).toBe('custom-provider');
});
@ -23,9 +29,32 @@ describe('aiProviderPresentation', () => {
expect(getProviderFromModelId('o4-mini')).toBe('openai');
expect(getProviderFromModelId('deepseek-r1')).toBe('deepseek');
expect(getProviderFromModelId('gemini-2.5-pro')).toBe('gemini');
expect(getProviderFromModelId('glm-5.2')).toBe('zai');
expect(getProviderFromModelId('groq-fast-model')).toBe('groq');
expect(getProviderFromModelId('mistral-large-latest')).toBe('mistral');
expect(getProviderFromModelId('cerebras/llama')).toBe('cerebras');
expect(getProviderFromModelId('llama3.1')).toBe('ollama');
});
it('formats direct OpenAI-compatible provider routes with provider labels', () => {
expect(formatAIModelRouteLabel('zai:glm-5.2')).toBe('Z.ai: GLM 5.2');
expect(formatAIModelRouteLabel('groq:llama-3.3-70b-versatile')).toBe(
'Groq: Llama 3.3 70B Versatile',
);
expect(formatAIModelRouteLabel('mistral:mistral-large-latest')).toBe(
'Mistral: Mistral Large Latest',
);
expect(formatAIModelRouteLabel('cerebras:llama-4-scout-17b-16e-instruct')).toBe(
'Cerebras: Llama 4 Scout 17B 16e Instruct',
);
expect(formatAIModelRouteLabel('together:meta-llama/Llama-3.3-70B-Instruct-Turbo')).toBe(
'Together AI: Meta Llama/Llama 3.3 70B Instruct Turbo',
);
expect(
formatAIModelRouteLabel('fireworks:accounts/fireworks/models/llama-v3p1-70b-instruct'),
).toBe('Fireworks AI: Accounts/Fireworks/Models/Llama V3p1 70B Instruct');
});
it('keeps OpenRouter-routed model labels distinct from direct provider models', () => {
expect(
formatAIModelRouteLabel({

View file

@ -6,6 +6,12 @@ export const AI_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
openrouter: 'OpenRouter',
deepseek: 'DeepSeek',
gemini: 'Google Gemini',
zai: 'Z.ai',
groq: 'Groq',
mistral: 'Mistral',
cerebras: 'Cerebras',
together: 'Together AI',
fireworks: 'Fireworks AI',
ollama: 'Ollama',
pulse: 'Pulse',
};
@ -45,6 +51,18 @@ export function getProviderFromModelId(modelId: string): AIProvider | string {
if (modelId.includes('gemini')) {
return 'gemini';
}
if (modelId.includes('glm')) {
return 'zai';
}
if (modelId.includes('groq')) {
return 'groq';
}
if (modelId.includes('mistral')) {
return 'mistral';
}
if (modelId.includes('cerebras')) {
return 'cerebras';
}
return 'ollama';
}
@ -94,6 +112,7 @@ const MODEL_TOKEN_DISPLAY_NAMES: Record<string, string> = {
deepseek: 'DeepSeek',
flash: 'Flash',
gemini: 'Gemini',
glm: 'GLM',
gpt: 'GPT',
haiku: 'Haiku',
instruct: 'Instruct',
@ -169,15 +188,24 @@ const titleizeModelToken = (token: string): string => {
if (/^[rv][0-9]/.test(normalized)) {
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
if (/^[0-9]+b$/.test(normalized)) {
return normalized.toUpperCase();
}
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
};
const titleizeModelRouteId = (modelId: string): string =>
modelId
.split(/[-_\s]+/)
.split('/')
.map((segment) =>
segment
.split(/[-_\s]+/)
.filter(Boolean)
.map(titleizeModelToken)
.join(' '),
)
.filter(Boolean)
.map(titleizeModelToken)
.join(' ');
.join('/');
const gatewayFallbackLabel = (model: AIModelRouteLabelInput): string => {
if (explicitModelNameForLabel(model)) return '';

View file

@ -66,7 +66,7 @@ func EvaluatePatrolConfigReadiness(cfg *config.AIConfig) PatrolConfigReadiness {
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, PatrolFailureCauseModelNotSelected, "No concrete Patrol model is selected.")
}
if !cfg.HasProvider(provider) {
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, PatrolFailureCauseModelProviderUnconfigured, fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", provider))
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, PatrolFailureCauseModelProviderUnconfigured, fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", config.AIProviderDisplayName(provider)))
}
status, cause, message := PatrolToolReadinessForModel(provider, model)
@ -88,8 +88,8 @@ func PatrolToolReadinessForModel(provider, model string) (string, PatrolFailureC
strings.Contains(normalizedModel, "reasoner") ||
strings.Contains(normalizedModel, "qwq"):
return PatrolReadinessNotReady, PatrolFailureCauseModelUnsupportedTools, "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings."
case provider == config.AIProviderOpenRouter:
return PatrolReadinessWarning, PatrolFailureCauseModelToolSupportUnverified, "OpenRouter routes vary by model and endpoint. Patrol will fail closed if the routed model rejects tools or tool_choice."
case providerDefinitionIsGateway(provider):
return PatrolReadinessWarning, PatrolFailureCauseModelToolSupportUnverified, fmt.Sprintf("%s routes vary by model and endpoint. Patrol will fail closed if the routed model rejects tools or tool_choice.", config.AIProviderDisplayName(provider))
case provider == config.AIProviderOllama:
return PatrolReadinessWarning, PatrolFailureCauseModelToolSupportUnverified, "Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification."
default:
@ -97,6 +97,11 @@ func PatrolToolReadinessForModel(provider, model string) (string, PatrolFailureC
}
}
func providerDefinitionIsGateway(provider string) bool {
def, ok := config.LookupAIProviderDefinition(provider)
return ok && def.Gateway
}
func patrolDeepSeekToolReadiness(normalizedModel string) (string, PatrolFailureCause, string) {
model := patrolDeepSeekModelName(normalizedModel)
switch {

View file

@ -422,6 +422,7 @@ func TestReconcileStaleFindings_VerificationCapDefersExcessCandidates(t *testing
// Distinct resources so the store keeps all four.
for i, f := range findings {
f.ResourceID = fmt.Sprintf("vm-%d", 100+i)
f.ResourceName = fmt.Sprintf("web-%d", i+1)
}
p := newTestPatrolWithFindings(findings)
calls := 0

View file

@ -2,6 +2,7 @@ package providers
import (
"fmt"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
@ -35,6 +36,16 @@ func NewForProvider(cfg *config.AIConfig, provider, model string) (Provider, err
// Get the configured timeout
timeout := cfg.GetRequestTimeout()
provider = strings.TrimSpace(provider)
if config.IsOpenAICompatibleProvider(provider) {
apiKey := cfg.GetAPIKeyForProvider(provider)
if apiKey == "" {
return nil, fmt.Errorf("%s API key not configured", config.AIProviderDisplayName(provider))
}
baseURL := cfg.GetBaseURLForProvider(provider)
return NewOpenAICompatibleClient(provider, apiKey, model, baseURL, timeout), nil
}
switch provider {
case config.AIProviderAnthropic:
@ -47,30 +58,6 @@ func NewForProvider(cfg *config.AIConfig, provider, model string) (Provider, err
}
return NewAnthropicClient(apiKey, model, timeout), nil
case config.AIProviderOpenAI:
apiKey := cfg.GetAPIKeyForProvider(config.AIProviderOpenAI)
if apiKey == "" {
return nil, fmt.Errorf("OpenAI API key not configured")
}
baseURL := cfg.GetBaseURLForProvider(config.AIProviderOpenAI)
return NewOpenAIClient(apiKey, model, baseURL, timeout), nil
case config.AIProviderOpenRouter:
apiKey := cfg.GetAPIKeyForProvider(config.AIProviderOpenRouter)
if apiKey == "" {
return nil, fmt.Errorf("OpenRouter API key not configured")
}
baseURL := cfg.GetBaseURLForProvider(config.AIProviderOpenRouter)
return NewOpenAIClient(apiKey, model, baseURL, timeout), nil
case config.AIProviderDeepSeek:
apiKey := cfg.GetAPIKeyForProvider(config.AIProviderDeepSeek)
if apiKey == "" {
return nil, fmt.Errorf("DeepSeek API key not configured")
}
baseURL := cfg.GetBaseURLForProvider(config.AIProviderDeepSeek)
return NewOpenAIClient(apiKey, model, baseURL, timeout), nil
case config.AIProviderOllama:
baseURL := cfg.GetBaseURLForProvider(config.AIProviderOllama)
return NewOllamaClientWithKeepAlive(model, baseURL, cfg.OllamaUsername, cfg.OllamaPassword, cfg.GetOllamaKeepAlive(), timeout)

View file

@ -129,9 +129,8 @@ func TestNewFromConfig_OpenRouterWithAPIKey(t *testing.T) {
if provider == nil {
t.Fatal("Provider should not be nil")
}
// OpenRouter uses OpenAI-compatible client
if provider.Name() != "openai" {
t.Errorf("Expected provider name 'openai' (OpenRouter uses OpenAI client), got '%s'", provider.Name())
if provider.Name() != config.AIProviderOpenRouter {
t.Errorf("Expected provider name %q, got %q", config.AIProviderOpenRouter, provider.Name())
}
}
@ -176,9 +175,8 @@ func TestNewFromConfig_DeepSeekWithAPIKey(t *testing.T) {
if provider == nil {
t.Fatal("Provider should not be nil")
}
// DeepSeek uses OpenAI-compatible client
if provider.Name() != "openai" {
t.Errorf("Expected provider name 'openai' (DeepSeek uses OpenAI client), got '%s'", provider.Name())
if provider.Name() != config.AIProviderDeepSeek {
t.Errorf("Expected provider name %q, got %q", config.AIProviderDeepSeek, provider.Name())
}
}
@ -313,9 +311,8 @@ func TestNewForProvider_OpenRouter(t *testing.T) {
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// OpenRouter uses OpenAI-compatible client
if provider.Name() != "openai" {
t.Errorf("Expected provider name 'openai', got '%s'", provider.Name())
if provider.Name() != config.AIProviderOpenRouter {
t.Errorf("Expected provider name %q, got %q", config.AIProviderOpenRouter, provider.Name())
}
}
@ -338,9 +335,8 @@ func TestNewForProvider_DeepSeek(t *testing.T) {
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// DeepSeek uses OpenAI-compatible client
if provider.Name() != "openai" {
t.Errorf("Expected provider name 'openai', got '%s'", provider.Name())
if provider.Name() != config.AIProviderDeepSeek {
t.Errorf("Expected provider name %q, got %q", config.AIProviderDeepSeek, provider.Name())
}
}
@ -354,6 +350,78 @@ func TestNewForProvider_DeepSeekNoAPIKey(t *testing.T) {
}
}
func TestNewForProvider_OpenAICompatibleRegistryProviders(t *testing.T) {
tests := []struct {
name string
provider string
model string
configure func(*config.AIConfig)
}{
{
name: "Z.ai",
provider: config.AIProviderZai,
model: "glm-5.2",
configure: func(cfg *config.AIConfig) {
cfg.ZaiAPIKey = "test-key"
},
},
{
name: "Groq",
provider: config.AIProviderGroq,
model: "llama-3.3-70b-versatile",
configure: func(cfg *config.AIConfig) {
cfg.GroqAPIKey = "test-key"
},
},
{
name: "Mistral",
provider: config.AIProviderMistral,
model: "mistral-large-latest",
configure: func(cfg *config.AIConfig) {
cfg.MistralAPIKey = "test-key"
},
},
{
name: "Cerebras",
provider: config.AIProviderCerebras,
model: "llama-4-scout-17b-16e-instruct",
configure: func(cfg *config.AIConfig) {
cfg.CerebrasAPIKey = "test-key"
},
},
{
name: "Together",
provider: config.AIProviderTogether,
model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
configure: func(cfg *config.AIConfig) {
cfg.TogetherAPIKey = "test-key"
},
},
{
name: "Fireworks",
provider: config.AIProviderFireworks,
model: "accounts/fireworks/models/llama-v3p1-70b-instruct",
configure: func(cfg *config.AIConfig) {
cfg.FireworksAPIKey = "test-key"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.AIConfig{Enabled: true}
tt.configure(cfg)
provider, err := NewForProvider(cfg, tt.provider, tt.model)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if provider.Name() != tt.provider {
t.Errorf("Expected provider name %q, got %q", tt.provider, provider.Name())
}
})
}
}
func TestNewForProvider_Ollama(t *testing.T) {
cfg := &config.AIConfig{
Enabled: true,

View file

@ -37,6 +37,7 @@ const (
// OpenAIClient implements the Provider interface for OpenAI's API
// Also works with OpenAI-compatible APIs like DeepSeek
type OpenAIClient struct {
providerName string
apiKey string
model string
baseURL string
@ -52,38 +53,26 @@ type OpenAIClient struct {
// NewOpenAIClient creates a new OpenAI API client
// timeout is optional - pass 0 to use the default 5 minute timeout
func NewOpenAIClient(apiKey, model, baseURL string, timeout time.Duration) *OpenAIClient {
return NewOpenAICompatibleClient("openai", apiKey, model, baseURL, timeout)
}
// NewOpenAICompatibleClient creates a client for OpenAI-compatible chat APIs.
func NewOpenAICompatibleClient(providerName, apiKey, model, baseURL string, timeout time.Duration) *OpenAIClient {
providerName = strings.ToLower(strings.TrimSpace(providerName))
if providerName == "" {
providerName = "openai"
}
if baseURL == "" {
baseURL = openaiAPIURL
} else {
// Normalize baseURL: ensure it ends with /chat/completions for chat endpoint
// Users may provide URLs like "https://openrouter.ai/api/v1" which need the path appended
baseURL = strings.TrimSuffix(baseURL, "/")
if !strings.HasSuffix(baseURL, "/chat/completions") {
// If URL ends with /v1, append /chat/completions
// Otherwise append /v1/chat/completions
if strings.HasSuffix(baseURL, "/v1") {
baseURL = baseURL + "/chat/completions"
} else if strings.HasSuffix(baseURL, "/completions") {
// URL already has /completions, make it /chat/completions
baseURL = strings.TrimSuffix(baseURL, "/completions") + "/chat/completions"
} else {
// Assume it's a base URL, append full path
baseURL = baseURL + "/v1/chat/completions"
}
}
}
// Strip provider prefix if present - the model should be just the model name
if strings.HasPrefix(model, "openai:") {
model = strings.TrimPrefix(model, "openai:")
} else if strings.HasPrefix(model, "openrouter:") {
model = strings.TrimPrefix(model, "openrouter:")
} else if strings.HasPrefix(model, "deepseek:") {
model = strings.TrimPrefix(model, "deepseek:")
baseURL = normalizeOpenAICompatibleChatURL(baseURL)
}
model = stripOpenAICompatibleProviderPrefix(providerName, model)
if timeout <= 0 {
timeout = 300 * time.Second // Default 5 minutes
}
return &OpenAIClient{
providerName: providerName,
apiKey: apiKey,
model: model,
baseURL: baseURL,
@ -94,6 +83,60 @@ func NewOpenAIClient(apiKey, model, baseURL string, timeout time.Duration) *Open
}
}
func normalizeOpenAICompatibleChatURL(baseURL string) string {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return openaiAPIURL
}
trimmed := strings.TrimRight(baseURL, "/")
if strings.HasSuffix(trimmed, "/chat/completions") {
return trimmed
}
if strings.HasSuffix(trimmed, "/completions") {
return strings.TrimSuffix(trimmed, "/completions") + "/chat/completions"
}
u, err := url.Parse(trimmed)
if err != nil || u.Scheme == "" || u.Host == "" {
if strings.Contains(trimmed, "/") {
return trimmed + "/chat/completions"
}
return trimmed + "/v1/chat/completions"
}
path := strings.TrimRight(u.Path, "/")
if path == "" {
u.Path = "/v1/chat/completions"
} else {
u.Path = path + "/chat/completions"
}
return u.String()
}
func stripOpenAICompatibleProviderPrefix(providerName, model string) string {
model = strings.TrimSpace(model)
prefix := strings.ToLower(strings.TrimSpace(providerName)) + ":"
if prefix != ":" && strings.HasPrefix(strings.ToLower(model), prefix) {
return model[len(prefix):]
}
for _, knownPrefix := range []string{
"openai:",
"openrouter:",
"deepseek:",
"zai:",
"groq:",
"mistral:",
"cerebras:",
"together:",
"fireworks:",
} {
if strings.HasPrefix(strings.ToLower(model), knownPrefix) {
return model[len(knownPrefix):]
}
}
return model
}
func newOpenAIStreamHTTPClient(timeout time.Duration) *http.Client {
client := &http.Client{}
if transport, ok := http.DefaultTransport.(*http.Transport); ok {
@ -167,6 +210,9 @@ func isRetryableOpenAIStreamStatus(statusCode int) bool {
// Name returns the provider name
func (c *OpenAIClient) Name() string {
if strings.TrimSpace(c.providerName) != "" {
return c.providerName
}
return "openai"
}
@ -254,11 +300,11 @@ type openaiErrorDetail struct {
// isDeepSeek returns true if this client is configured for DeepSeek
func (c *OpenAIClient) isDeepSeek() bool {
return strings.Contains(c.baseURL, "deepseek.com")
return c.Name() == "deepseek" || strings.Contains(c.baseURL, "deepseek.com")
}
func (c *OpenAIClient) isOpenRouter() bool {
return strings.Contains(c.baseURL, "openrouter.ai")
return c.Name() == "openrouter" || strings.Contains(c.baseURL, "openrouter.ai")
}
// isDeepSeekReasoner returns true if using DeepSeek's reasoning model
@ -372,20 +418,13 @@ func (c *OpenAIClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
// Use provided model or fall back to client default
model := req.Model
// Strip provider prefix if present - callers may pass the full "provider:model" string
if strings.HasPrefix(model, "openai:") {
model = strings.TrimPrefix(model, "openai:")
} else if strings.HasPrefix(model, "openrouter:") {
model = strings.TrimPrefix(model, "openrouter:")
} else if strings.HasPrefix(model, "deepseek:") {
model = strings.TrimPrefix(model, "deepseek:")
}
model = stripOpenAICompatibleProviderPrefix(c.Name(), model)
if model == "" {
model = c.model
}
// Debug log to trace model issues
log.Debug().Str("model", model).Str("req_model", req.Model).Str("c_model", c.model).Str("base_url", c.baseURL).Msg("OpenAI/DeepSeek chat request")
log.Debug().Str("provider", c.Name()).Str("model", model).Str("req_model", req.Model).Str("c_model", c.model).Str("base_url", c.baseURL).Msg("OpenAI-compatible chat request")
// Build request
openaiReq := openaiRequest{
@ -436,7 +475,7 @@ func (c *OpenAIClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
}
// Log actual model being sent (INFO level for visibility)
log.Info().Str("model_in_request", openaiReq.Model).Str("base_url", c.baseURL).Msg("sending OpenAI/DeepSeek request")
log.Info().Str("provider", c.Name()).Str("model_in_request", openaiReq.Model).Str("base_url", c.baseURL).Msg("sending OpenAI-compatible request")
body, err := json.Marshal(openaiReq)
if err != nil {
@ -788,13 +827,7 @@ func (c *OpenAIClient) ChatStream(ctx context.Context, req ChatRequest, callback
}
model := req.Model
if strings.HasPrefix(model, "openai:") {
model = strings.TrimPrefix(model, "openai:")
} else if strings.HasPrefix(model, "openrouter:") {
model = strings.TrimPrefix(model, "openrouter:")
} else if strings.HasPrefix(model, "deepseek:") {
model = strings.TrimPrefix(model, "deepseek:")
}
model = stripOpenAICompatibleProviderPrefix(c.Name(), model)
if model == "" {
model = c.model
}
@ -1188,6 +1221,25 @@ func (c *OpenAIClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
continue
}
if c.Name() != "openai" {
modelName := strings.TrimSpace(m.Name)
if modelName == "" {
modelName = m.ID
}
description := strings.TrimSpace(m.Description)
if description == "" && strings.TrimSpace(m.OwnedBy) != "" {
description = strings.TrimSpace(m.OwnedBy)
}
models = append(models, ModelInfo{
ID: m.ID,
Name: modelName,
Description: description,
CreatedAt: m.Created,
Notable: cache.IsNotable(c.Name(), m.ID, m.Created),
})
continue
}
// Filter to only chat-capable models
if strings.Contains(m.ID, "gpt") || strings.Contains(m.ID, "o1") ||
strings.Contains(m.ID, "o3") || strings.Contains(m.ID, "o4") ||

View file

@ -195,6 +195,55 @@ func TestNewOpenAIClient_BoundsStreamResponseHeaderTimeout(t *testing.T) {
assert.Equal(t, 2*time.Second, shortTimeoutClient.streamChunkTimeout)
}
func TestNewOpenAICompatibleClient_NormalizesProviderBasePaths(t *testing.T) {
tests := []struct {
name string
provider string
baseURL string
wantURL string
}{
{
name: "host only appends openai v1 chat path",
provider: "openai",
baseURL: "https://api.example.com",
wantURL: "https://api.example.com/v1/chat/completions",
},
{
name: "v1 path appends chat completions",
provider: "groq",
baseURL: "https://api.groq.com/openai/v1",
wantURL: "https://api.groq.com/openai/v1/chat/completions",
},
{
name: "v4 provider path keeps provider path",
provider: "zai",
baseURL: "https://api.z.ai/api/paas/v4",
wantURL: "https://api.z.ai/api/paas/v4/chat/completions",
},
{
name: "inference path keeps provider path",
provider: "fireworks",
baseURL: "https://api.fireworks.ai/inference/v1",
wantURL: "https://api.fireworks.ai/inference/v1/chat/completions",
},
{
name: "completion endpoint becomes chat completions",
provider: "mistral",
baseURL: "https://api.mistral.ai/v1/completions",
wantURL: "https://api.mistral.ai/v1/chat/completions",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewOpenAICompatibleClient(tt.provider, "sk-test", tt.provider+":model-id", tt.baseURL, 0)
assert.Equal(t, tt.provider, client.Name())
assert.Equal(t, tt.wantURL, client.baseURL)
assert.Equal(t, "model-id", client.model)
})
}
}
func TestOpenAIClient_ChatStream_TimesOutWaitingForFirstStreamChunk(t *testing.T) {
body := newBlockingReadCloser()
client := NewOpenAIClient("sk-test", "gpt-4", "https://example.invalid/v1", time.Second)

View file

@ -4333,7 +4333,7 @@ func (s *Service) ListModelsWithCache(ctx context.Context) ([]providers.ModelInf
}
s.modelsCache.mu.Unlock()
providersList := []string{config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderOpenRouter, config.AIProviderDeepSeek, config.AIProviderGemini, config.AIProviderOllama}
providersList := cfg.GetConfiguredProviders()
allCached := true
@ -4456,35 +4456,20 @@ func mergeProviderCatalogFallbackModels(providerName string, models []providers.
}
func providerCatalogFallbackModels(providerName string) []providers.ModelInfo {
switch providerName {
case config.AIProviderDeepSeek:
return []providers.ModelInfo{
{
ID: config.DeepSeekModelV4Flash,
Name: "DeepSeek V4 Flash",
Description: "DeepSeek: current V4 Flash direct API model",
Notable: true,
},
{
ID: config.DeepSeekModelV4Pro,
Name: "DeepSeek V4 Pro",
Description: "DeepSeek: current V4 Pro direct API model",
Notable: true,
},
{
ID: config.DeepSeekModelLegacyChat,
Name: "DeepSeek Chat (legacy alias)",
Description: "DeepSeek: legacy alias currently routing to V4 Flash",
},
{
ID: config.DeepSeekModelLegacyReasoner,
Name: "DeepSeek Reasoner (legacy alias)",
Description: "DeepSeek: legacy alias currently routing to V4 Flash",
},
}
default:
fallbacks := config.AIProviderFallbackModels(providerName)
if len(fallbacks) == 0 {
return nil
}
models := make([]providers.ModelInfo, 0, len(fallbacks))
for _, fallback := range fallbacks {
models = append(models, providers.ModelInfo{
ID: fallback.ID,
Name: fallback.Name,
Description: fallback.Description,
Notable: fallback.Notable,
})
}
return models
}
func buildModelsCacheKey(cfg *config.AIConfig) string {
@ -4497,6 +4482,8 @@ func buildModelsCacheKey(cfg *config.AIConfig) string {
b.WriteString(strings.Join(cfg.GetConfiguredProviders(), ","))
b.WriteString("|auth=")
b.WriteString(string(cfg.AuthMethod))
b.WriteString("|keys=")
b.WriteString(aiCredentialCacheSignature(cfg))
b.WriteString("|openai_base=")
b.WriteString(cfg.OpenAIBaseURL)
@ -4511,24 +4498,26 @@ func buildModelsCacheKey(cfg *config.AIConfig) string {
return b.String()
}
func aiCredentialCacheSignature(cfg *config.AIConfig) string {
if cfg == nil {
return ""
}
var b strings.Builder
for _, providerName := range cfg.GetConfiguredProviders() {
b.WriteString(providerName)
b.WriteString("=")
if key := cfg.GetAPIKeyForProvider(providerName); key != "" {
sum := sha256.Sum256([]byte(key))
b.WriteString(hex.EncodeToString(sum[:]))
}
b.WriteString(";")
}
return b.String()
}
// providerDisplayName returns a user-friendly name for a provider
func providerDisplayName(provider string) string {
switch provider {
case config.AIProviderAnthropic:
return "Anthropic"
case config.AIProviderOpenAI:
return "OpenAI"
case config.AIProviderOpenRouter:
return "OpenRouter"
case config.AIProviderDeepSeek:
return "DeepSeek"
case config.AIProviderGemini:
return "Google Gemini"
case config.AIProviderOllama:
return "Ollama"
default:
return provider
}
return config.AIProviderDisplayName(provider)
}
// Reload reloads the AI configuration (call after settings change)

View file

@ -1460,7 +1460,7 @@ func TestHandleChat_RehydratesPatrolRunHandoffContextFromBackend(t *testing.T) {
assert.Equal(t, chat.HandoffMetadata{
Kind: "patrol_run",
RunID: "run-runtime-error",
RunType: "Scoped run",
RunType: "Targeted check",
RunStatus: "error",
RuntimeFailure: true,
}, reqArg.HandoffMetadata)
@ -1518,12 +1518,12 @@ func TestHandleChat_RehydratesStoredPatrolRunHandoffMetadataForFollowUp(t *testi
reqArg := args.Get(1).(chat.ExecuteRequest)
assert.Equal(t, "what changed?", reqArg.Prompt)
assert.Contains(t, reqArg.HandoffContext, "Run ID: run-stored")
assert.Contains(t, reqArg.HandoffContext, "Run Type: Verification check")
assert.Contains(t, reqArg.HandoffContext, "Run Type: Follow-up check")
assert.Equal(t, []chat.HandoffResource{{ID: "storage-1", Type: "storage"}}, reqArg.HandoffResources)
assert.Equal(t, chat.HandoffMetadata{
Kind: "patrol_run",
RunID: "run-stored",
RunType: "Verification check",
RunType: "Follow-up check",
RunStatus: "healthy",
}, reqArg.HandoffMetadata)
})

View file

@ -1673,6 +1673,12 @@ func shouldRestartAIChat(req AISettingsUpdateRequest) bool {
req.OpenAIAPIKey != nil ||
req.OpenRouterAPIKey != nil ||
req.DeepSeekAPIKey != nil ||
req.ZaiAPIKey != nil ||
req.GroqAPIKey != nil ||
req.MistralAPIKey != nil ||
req.CerebrasAPIKey != nil ||
req.TogetherAPIKey != nil ||
req.FireworksAPIKey != nil ||
req.GeminiAPIKey != nil ||
req.OllamaBaseURL != nil ||
req.OllamaUsername != nil ||
@ -1683,6 +1689,12 @@ func shouldRestartAIChat(req AISettingsUpdateRequest) bool {
req.ClearOpenAIKey != nil ||
req.ClearOpenRouterKey != nil ||
req.ClearDeepSeekKey != nil ||
req.ClearZaiKey != nil ||
req.ClearGroqKey != nil ||
req.ClearMistralKey != nil ||
req.ClearCerebrasKey != nil ||
req.ClearTogetherKey != nil ||
req.ClearFireworksKey != nil ||
req.ClearGeminiKey != nil ||
req.ClearOllamaURL != nil ||
req.ClearOllamaUsername != nil ||
@ -1737,6 +1749,12 @@ func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool {
req.OpenAIAPIKey != nil ||
req.OpenRouterAPIKey != nil ||
req.DeepSeekAPIKey != nil ||
req.ZaiAPIKey != nil ||
req.GroqAPIKey != nil ||
req.MistralAPIKey != nil ||
req.CerebrasAPIKey != nil ||
req.TogetherAPIKey != nil ||
req.FireworksAPIKey != nil ||
req.GeminiAPIKey != nil ||
req.OllamaBaseURL != nil ||
req.OllamaUsername != nil ||
@ -1747,6 +1765,12 @@ func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool {
req.ClearOpenAIKey != nil ||
req.ClearOpenRouterKey != nil ||
req.ClearDeepSeekKey != nil ||
req.ClearZaiKey != nil ||
req.ClearGroqKey != nil ||
req.ClearMistralKey != nil ||
req.ClearCerebrasKey != nil ||
req.ClearTogetherKey != nil ||
req.ClearFireworksKey != nil ||
req.ClearGeminiKey != nil ||
req.ClearOllamaURL != nil ||
req.ClearOllamaUsername != nil ||
@ -2279,18 +2303,25 @@ type AISettingsResponse struct {
UseProactiveThresholds bool `json:"use_proactive_thresholds"` // true if patrol warns before thresholds (false = use exact thresholds)
AvailableModels []providers.ModelInfo `json:"available_models"` // List of models for current provider
// Multi-provider credentials - shows which providers are configured
AnthropicConfigured bool `json:"anthropic_configured"` // true if Anthropic API key is set
OpenAIConfigured bool `json:"openai_configured"` // true if OpenAI API key is set
OpenRouterConfigured bool `json:"openrouter_configured"` // true if OpenRouter API key is set
DeepSeekConfigured bool `json:"deepseek_configured"` // true if DeepSeek API key is set
GeminiConfigured bool `json:"gemini_configured"` // true if Gemini API key is set
OllamaConfigured bool `json:"ollama_configured"` // true (always available for attempt)
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
OllamaPasswordSet bool `json:"ollama_password_set"` // true if an Ollama password is stored
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses server default
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
AnthropicConfigured bool `json:"anthropic_configured"` // true if Anthropic API key is set
OpenAIConfigured bool `json:"openai_configured"` // true if OpenAI API key is set
OpenRouterConfigured bool `json:"openrouter_configured"` // true if OpenRouter API key is set
DeepSeekConfigured bool `json:"deepseek_configured"` // true if DeepSeek API key is set
GeminiConfigured bool `json:"gemini_configured"` // true if Gemini API key is set
ZaiConfigured bool `json:"zai_configured"` // true if Z.ai (Zhipu) API key is set
GroqConfigured bool `json:"groq_configured"` // true if Groq API key is set
MistralConfigured bool `json:"mistral_configured"` // true if Mistral API key is set
CerebrasConfigured bool `json:"cerebras_configured"` // true if Cerebras API key is set
TogetherConfigured bool `json:"together_configured"` // true if Together AI API key is set
FireworksConfigured bool `json:"fireworks_configured"` // true if Fireworks AI API key is set
OllamaConfigured bool `json:"ollama_configured"` // true (always available for attempt)
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
OllamaPasswordSet bool `json:"ollama_password_set"` // true if an Ollama password is stored
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses server default
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
Providers []AIProviderDefinitionResponse `json:"providers"` // Canonical provider registry metadata
// Cost controls
CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"`
// Request timeout (seconds) - for slow hardware running local models
@ -2310,6 +2341,27 @@ type AISettingsResponse struct {
PatrolPreflight *PatrolPreflightSnapshot `json:"patrol_preflight,omitempty"`
}
// AIProviderDefinitionResponse exposes provider metadata without credentials.
type AIProviderDefinitionResponse struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Protocol string `json:"protocol"`
DefaultModel string `json:"default_model,omitempty"`
DefaultBaseURL string `json:"default_base_url,omitempty"`
APIKeyField string `json:"api_key_field,omitempty"`
ConfiguredField string `json:"configured_field,omitempty"`
ClearKeyField string `json:"clear_key_field,omitempty"`
BaseURLField string `json:"base_url_field,omitempty"`
RequiresAPIKey bool `json:"requires_api_key"`
UserConfigurable bool `json:"user_configurable"`
Gateway bool `json:"gateway"`
Configured bool `json:"configured"`
ModelsDevProviderID string `json:"models_dev_provider_id,omitempty"`
EnvVars []string `json:"env_vars"`
DocsURL string `json:"docs_url,omitempty"`
}
// PatrolPreflightSnapshot is the API-shaped projection of the cached
// PatrolPreflightResult plus the wall-clock time it was recorded.
type PatrolPreflightSnapshot struct {
@ -2337,6 +2389,9 @@ func (r AISettingsResponse) NormalizeCollections() AISettingsResponse {
if r.ConfiguredProviders == nil {
r.ConfiguredProviders = []string{}
}
if r.Providers == nil {
r.Providers = []AIProviderDefinitionResponse{}
}
if r.ProtectedGuests == nil {
r.ProtectedGuests = []string{}
}
@ -2346,6 +2401,36 @@ func (r AISettingsResponse) NormalizeCollections() AISettingsResponse {
return r
}
func aiProviderDefinitionResponses(settings *config.AIConfig) []AIProviderDefinitionResponse {
defs := config.AIProviderDefinitions()
responses := make([]AIProviderDefinitionResponse, 0, len(defs))
for _, def := range defs {
if !def.UserConfigurable {
continue
}
responses = append(responses, AIProviderDefinitionResponse{
ID: def.ID,
DisplayName: def.DisplayName,
Description: def.Description,
Protocol: string(def.Protocol),
DefaultModel: config.DefaultModelForProvider(def.ID),
DefaultBaseURL: def.DefaultBaseURL,
APIKeyField: def.APIKeyField,
ConfiguredField: def.ConfiguredField,
ClearKeyField: def.ClearKeyField,
BaseURLField: def.BaseURLField,
RequiresAPIKey: def.RequiresAPIKey,
UserConfigurable: def.UserConfigurable,
Gateway: def.Gateway,
Configured: settings != nil && settings.HasProvider(def.ID),
ModelsDevProviderID: def.ModelsDevProviderID,
EnvVars: append([]string(nil), def.EnvVars...),
DocsURL: def.DocsURL,
})
}
return responses
}
// AISettingsUpdateRequest is the request body for PUT /api/settings/ai
type AISettingsUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"`
@ -2373,6 +2458,12 @@ type AISettingsUpdateRequest struct {
OpenRouterAPIKey *string `json:"openrouter_api_key,omitempty"` // Set OpenRouter API key
DeepSeekAPIKey *string `json:"deepseek_api_key,omitempty"` // Set DeepSeek API key
GeminiAPIKey *string `json:"gemini_api_key,omitempty"` // Set Gemini API key
ZaiAPIKey *string `json:"zai_api_key,omitempty"` // Set Z.ai (Zhipu) API key
GroqAPIKey *string `json:"groq_api_key,omitempty"` // Set Groq API key
MistralAPIKey *string `json:"mistral_api_key,omitempty"` // Set Mistral API key
CerebrasAPIKey *string `json:"cerebras_api_key,omitempty"` // Set Cerebras API key
TogetherAPIKey *string `json:"together_api_key,omitempty"` // Set Together AI API key
FireworksAPIKey *string `json:"fireworks_api_key,omitempty"` // Set Fireworks AI API key
OllamaBaseURL *string `json:"ollama_base_url,omitempty"` // Set Ollama server URL
OllamaUsername *string `json:"ollama_username,omitempty"` // Set Ollama Basic Auth username
OllamaPassword *string `json:"ollama_password,omitempty"` // Set Ollama Basic Auth password
@ -2384,6 +2475,12 @@ type AISettingsUpdateRequest struct {
ClearOpenRouterKey *bool `json:"clear_openrouter_key,omitempty"` // Clear OpenRouter API key
ClearDeepSeekKey *bool `json:"clear_deepseek_key,omitempty"` // Clear DeepSeek API key
ClearGeminiKey *bool `json:"clear_gemini_key,omitempty"` // Clear Gemini API key
ClearZaiKey *bool `json:"clear_zai_key,omitempty"` // Clear Z.ai (Zhipu) API key
ClearGroqKey *bool `json:"clear_groq_key,omitempty"` // Clear Groq API key
ClearMistralKey *bool `json:"clear_mistral_key,omitempty"` // Clear Mistral API key
ClearCerebrasKey *bool `json:"clear_cerebras_key,omitempty"` // Clear Cerebras API key
ClearTogetherKey *bool `json:"clear_together_key,omitempty"` // Clear Together AI API key
ClearFireworksKey *bool `json:"clear_fireworks_key,omitempty"` // Clear Fireworks AI API key
ClearOllamaURL *bool `json:"clear_ollama_url,omitempty"` // Clear Ollama URL
ClearOllamaUsername *bool `json:"clear_ollama_username,omitempty"` // Clear Ollama Basic Auth username
ClearOllamaPassword *bool `json:"clear_ollama_password,omitempty"` // Clear Ollama Basic Auth password
@ -2511,6 +2608,12 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter),
DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek),
GeminiConfigured: settings.HasProvider(config.AIProviderGemini),
ZaiConfigured: settings.HasProvider(config.AIProviderZai),
GroqConfigured: settings.HasProvider(config.AIProviderGroq),
MistralConfigured: settings.HasProvider(config.AIProviderMistral),
CerebrasConfigured: settings.HasProvider(config.AIProviderCerebras),
TogetherConfigured: settings.HasProvider(config.AIProviderTogether),
FireworksConfigured: settings.HasProvider(config.AIProviderFireworks),
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OllamaUsername: settings.OllamaUsername,
@ -2518,6 +2621,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
OpenAIBaseURL: settings.OpenAIBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
Providers: aiProviderDefinitionResponses(settings),
CostBudgetUSD30d: settings.CostBudgetUSD30d,
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature),
@ -2645,6 +2749,36 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
} else if req.DeepSeekAPIKey != nil {
settings.DeepSeekAPIKey = strings.TrimSpace(*req.DeepSeekAPIKey)
}
if req.ClearZaiKey != nil && *req.ClearZaiKey {
settings.ZaiAPIKey = ""
} else if req.ZaiAPIKey != nil {
settings.ZaiAPIKey = strings.TrimSpace(*req.ZaiAPIKey)
}
if req.ClearGroqKey != nil && *req.ClearGroqKey {
settings.GroqAPIKey = ""
} else if req.GroqAPIKey != nil {
settings.GroqAPIKey = strings.TrimSpace(*req.GroqAPIKey)
}
if req.ClearMistralKey != nil && *req.ClearMistralKey {
settings.MistralAPIKey = ""
} else if req.MistralAPIKey != nil {
settings.MistralAPIKey = strings.TrimSpace(*req.MistralAPIKey)
}
if req.ClearCerebrasKey != nil && *req.ClearCerebrasKey {
settings.CerebrasAPIKey = ""
} else if req.CerebrasAPIKey != nil {
settings.CerebrasAPIKey = strings.TrimSpace(*req.CerebrasAPIKey)
}
if req.ClearTogetherKey != nil && *req.ClearTogetherKey {
settings.TogetherAPIKey = ""
} else if req.TogetherAPIKey != nil {
settings.TogetherAPIKey = strings.TrimSpace(*req.TogetherAPIKey)
}
if req.ClearFireworksKey != nil && *req.ClearFireworksKey {
settings.FireworksAPIKey = ""
} else if req.FireworksAPIKey != nil {
settings.FireworksAPIKey = strings.TrimSpace(*req.FireworksAPIKey)
}
if req.ClearGeminiKey != nil && *req.ClearGeminiKey {
settings.GeminiAPIKey = ""
} else if req.GeminiAPIKey != nil {
@ -2964,6 +3098,12 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter),
DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek),
GeminiConfigured: settings.HasProvider(config.AIProviderGemini),
ZaiConfigured: settings.HasProvider(config.AIProviderZai),
GroqConfigured: settings.HasProvider(config.AIProviderGroq),
MistralConfigured: settings.HasProvider(config.AIProviderMistral),
CerebrasConfigured: settings.HasProvider(config.AIProviderCerebras),
TogetherConfigured: settings.HasProvider(config.AIProviderTogether),
FireworksConfigured: settings.HasProvider(config.AIProviderFireworks),
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OllamaUsername: settings.OllamaUsername,
@ -2971,6 +3111,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
OpenAIBaseURL: settings.OpenAIBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
Providers: aiProviderDefinitionResponses(settings),
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature),
ProtectedGuests: settings.GetProtectedGuests(),

View file

@ -41,16 +41,21 @@ func setupAIHandlerWithPatrol(t *testing.T) (*AISettingsHandler, *ai.PatrolServi
func addPatrolFinding(t *testing.T, patrol *ai.PatrolService, id string, detectedAt time.Time) *ai.Finding {
t.Helper()
return addPatrolFindingForResource(t, patrol, id, detectedAt, "vm-1", "CPU spike")
}
func addPatrolFindingForResource(t *testing.T, patrol *ai.PatrolService, id string, detectedAt time.Time, resourceID string, title string) *ai.Finding {
t.Helper()
finding := &ai.Finding{
ID: id,
Key: "key-" + id,
Severity: ai.FindingSeverityWarning,
Category: ai.FindingCategoryPerformance,
Title: "CPU spike",
Title: title,
Description: "CPU high",
ResourceID: "vm-1",
ResourceName: "vm-1",
ResourceID: resourceID,
ResourceName: resourceID,
ResourceType: "vm",
DetectedAt: detectedAt,
LastSeenAt: detectedAt,
@ -889,8 +894,8 @@ func TestHandleGetFindingsHistory_StartTimeFilter(t *testing.T) {
oldTime := time.Now().Add(-3 * time.Hour)
recentTime := time.Now().Add(-30 * time.Minute)
addPatrolFinding(t, patrol, "finding-old", oldTime)
addPatrolFinding(t, patrol, "finding-recent", recentTime)
addPatrolFindingForResource(t, patrol, "finding-old", oldTime, "vm-old", "Old CPU spike")
addPatrolFindingForResource(t, patrol, "finding-recent", recentTime, "vm-recent", "Recent CPU spike")
startTime := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339)
req := newLoopbackRequest(http.MethodGet, "/api/ai/patrol/history?start_time="+startTime, nil)

View file

@ -1400,6 +1400,12 @@ func TestContract_AISettingsUpdateProviderResolutionJSONSnapshot(t *testing.T) {
"openrouter_configured":false,
"deepseek_configured":false,
"gemini_configured":false,
"zai_configured":false,
"groq_configured":false,
"mistral_configured":false,
"cerebras_configured":false,
"together_configured":false,
"fireworks_configured":false,
"ollama_configured":true,
"ollama_base_url":%q,
"ollama_password_set":false,
@ -1429,7 +1435,7 @@ func TestContract_AISettingsUpdateProviderResolutionJSONSnapshot(t *testing.T) {
// timestamps and durations that vary per run; strip it from the
// snapshot comparison and rely on dedicated preflight tests for its
// shape.
assertJSONSnapshot(t, rec.Body.Bytes(), want, "patrol_preflight")
assertJSONSnapshot(t, rec.Body.Bytes(), want, "patrol_preflight", "providers")
}
func TestContract_PatrolStatusDoesNotSurfaceQuickstartActivation(t *testing.T) {
@ -1587,6 +1593,12 @@ func TestContract_AISettingsBYOKOverrideDoesNotExposeQuickstartInventoryJSONSnap
"openrouter_configured":false,
"deepseek_configured":false,
"gemini_configured":false,
"zai_configured":false,
"groq_configured":false,
"mistral_configured":false,
"cerebras_configured":false,
"together_configured":false,
"fireworks_configured":false,
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"ollama_password_set":false,
@ -1598,7 +1610,131 @@ func TestContract_AISettingsBYOKOverrideDoesNotExposeQuickstartInventoryJSONSnap
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
assertJSONSnapshot(t, rec.Body.Bytes(), want, "providers")
}
func TestContract_AISettingsProviderRegistryMetadata(t *testing.T) {
settings := config.NewDefaultAIConfig()
settings.OpenAIAPIKey = "sk-test"
settings.GroqAPIKey = "gsk-test"
settings.OllamaBaseURL = config.DefaultOllamaBaseURL
defs := aiProviderDefinitionResponses(settings)
gotIDs := make([]string, 0, len(defs))
byID := make(map[string]AIProviderDefinitionResponse, len(defs))
for _, def := range defs {
gotIDs = append(gotIDs, def.ID)
byID[def.ID] = def
}
wantIDs := []string{
"anthropic",
"openai",
"openrouter",
"deepseek",
"gemini",
"zai",
"groq",
"mistral",
"cerebras",
"together",
"fireworks",
"ollama",
}
if !reflect.DeepEqual(gotIDs, wantIDs) {
t.Fatalf("provider ids = %#v, want %#v", gotIDs, wantIDs)
}
if _, ok := byID["quickstart"]; ok {
t.Fatal("retired quickstart provider must not be user-configurable")
}
cases := map[string]struct {
displayName string
apiKeyField string
clearKeyField string
configuredField string
defaultBaseURL string
modelsDevProviderID string
configured bool
}{
"zai": {
displayName: "Z.ai",
apiKeyField: "zai_api_key",
clearKeyField: "clear_zai_key",
configuredField: "zai_configured",
defaultBaseURL: "https://api.z.ai/api/paas/v4",
modelsDevProviderID: "zai",
},
"groq": {
displayName: "Groq",
apiKeyField: "groq_api_key",
clearKeyField: "clear_groq_key",
configuredField: "groq_configured",
defaultBaseURL: "https://api.groq.com/openai/v1",
modelsDevProviderID: "groq",
configured: true,
},
"mistral": {
displayName: "Mistral",
apiKeyField: "mistral_api_key",
clearKeyField: "clear_mistral_key",
configuredField: "mistral_configured",
defaultBaseURL: "https://api.mistral.ai/v1",
modelsDevProviderID: "mistral",
},
"cerebras": {
displayName: "Cerebras",
apiKeyField: "cerebras_api_key",
clearKeyField: "clear_cerebras_key",
configuredField: "cerebras_configured",
defaultBaseURL: "https://api.cerebras.ai/v1",
modelsDevProviderID: "cerebras",
},
"together": {
displayName: "Together AI",
apiKeyField: "together_api_key",
clearKeyField: "clear_together_key",
configuredField: "together_configured",
defaultBaseURL: "https://api.together.xyz/v1",
modelsDevProviderID: "togetherai",
},
"fireworks": {
displayName: "Fireworks AI",
apiKeyField: "fireworks_api_key",
clearKeyField: "clear_fireworks_key",
configuredField: "fireworks_configured",
defaultBaseURL: "https://api.fireworks.ai/inference/v1",
modelsDevProviderID: "fireworks-ai",
},
}
for id, want := range cases {
got, ok := byID[id]
if !ok {
t.Fatalf("provider %q missing from registry response", id)
}
if got.DisplayName != want.displayName ||
got.Protocol != string(config.AIProviderProtocolOpenAICompatible) ||
got.APIKeyField != want.apiKeyField ||
got.ClearKeyField != want.clearKeyField ||
got.ConfiguredField != want.configuredField ||
got.DefaultBaseURL != want.defaultBaseURL ||
got.ModelsDevProviderID != want.modelsDevProviderID ||
!got.RequiresAPIKey ||
!got.UserConfigurable ||
got.Configured != want.configured {
t.Fatalf("provider %s metadata drifted: %#v", id, got)
}
}
if !byID["openai"].Configured {
t.Fatal("OpenAI provider metadata should reflect configured API key")
}
if byID["ollama"].RequiresAPIKey {
t.Fatal("Ollama provider metadata must not require an API key")
}
if !byID["ollama"].Configured {
t.Fatal("Ollama provider metadata should reflect the default local endpoint")
}
}
func TestContract_ChartMetricPointsPreserveMillisecondPrecision(t *testing.T) {
@ -3583,6 +3719,12 @@ func TestContract_HostedAISettingsDoesNotAutoBootstrapQuickstartJSONSnapshot(t *
"openrouter_configured":false,
"deepseek_configured":false,
"gemini_configured":false,
"zai_configured":false,
"groq_configured":false,
"mistral_configured":false,
"cerebras_configured":false,
"together_configured":false,
"fireworks_configured":false,
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"ollama_password_set":false,
@ -3594,7 +3736,7 @@ func TestContract_HostedAISettingsDoesNotAutoBootstrapQuickstartJSONSnapshot(t *
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
assertJSONSnapshot(t, rec.Body.Bytes(), want, "providers")
}
func TestContract_AISettingsRetiredQuickstartAliasJSONSnapshot(t *testing.T) {
@ -3646,6 +3788,12 @@ func TestContract_AISettingsRetiredQuickstartAliasJSONSnapshot(t *testing.T) {
"openrouter_configured":false,
"deepseek_configured":false,
"gemini_configured":false,
"zai_configured":false,
"groq_configured":false,
"mistral_configured":false,
"cerebras_configured":false,
"together_configured":false,
"fireworks_configured":false,
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"ollama_password_set":false,
@ -3657,7 +3805,7 @@ func TestContract_AISettingsRetiredQuickstartAliasJSONSnapshot(t *testing.T) {
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
assertJSONSnapshot(t, rec.Body.Bytes(), want, "providers")
if bytes.Contains(bytes.ToLower(rec.Body.Bytes()), []byte("quickstart")) {
t.Fatalf("expected AI settings payload to suppress legacy hosted quickstart aliases, got %s", rec.Body.Bytes())
}
@ -3713,6 +3861,12 @@ func TestContract_AISettingsOllamaAuthJSONSnapshot(t *testing.T) {
"openrouter_configured":false,
"deepseek_configured":false,
"gemini_configured":false,
"zai_configured":false,
"groq_configured":false,
"mistral_configured":false,
"cerebras_configured":false,
"together_configured":false,
"fireworks_configured":false,
"ollama_configured":true,
"ollama_base_url":"http://ollama.example:11434",
"ollama_username":"unai",
@ -3725,7 +3879,7 @@ func TestContract_AISettingsOllamaAuthJSONSnapshot(t *testing.T) {
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
assertJSONSnapshot(t, rec.Body.Bytes(), want, "providers")
}
func TestContract_VMInventoryExportCSVHeaders(t *testing.T) {
@ -4275,6 +4429,12 @@ func TestContract_HostedTenantAISettingsDoesNotAutoBootstrapQuickstartJSONSnapsh
"openrouter_configured":false,
"deepseek_configured":false,
"gemini_configured":false,
"zai_configured":false,
"groq_configured":false,
"mistral_configured":false,
"cerebras_configured":false,
"together_configured":false,
"fireworks_configured":false,
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"ollama_password_set":false,
@ -4286,7 +4446,7 @@ func TestContract_HostedTenantAISettingsDoesNotAutoBootstrapQuickstartJSONSnapsh
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
assertJSONSnapshot(t, rec.Body.Bytes(), want, "providers")
}
func TestContract_StripeWebhookHandlersUseCanonicalRuntimeDataDir(t *testing.T) {

View file

@ -255,7 +255,10 @@ func sortedKeys(set map[string]struct{}) []string {
return keys
}
var dynamicRouteAllowlist = []string{}
var dynamicRouteAllowlist = []string{
"agentcapabilities.OperationsLoopStatusCompatibilityPath",
"agentcapabilities.PatrolControlStatusCapabilityPath",
}
var publicRouteAllowlist = []string{
"/api/health",
@ -421,8 +424,6 @@ var allRouteAllowlist = []string{
"POST /api/maintenance-verifications/{reportId}/review",
"/api/agent/resource-context/{id}",
"/api/agent/fleet-context",
"/api/agent/patrol-control/status",
"/api/agent/operations-loop/status",
"/api/agent/capabilities",
"/api/agent/events",
"POST /api/actions/plan",

View file

@ -42,6 +42,12 @@ type AIConfig struct {
OpenRouterAPIKey string `json:"openrouter_api_key,omitempty"` // OpenRouter API key
DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` // DeepSeek API key
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // Google Gemini API key
ZaiAPIKey string `json:"zai_api_key,omitempty"` // Z.ai (Zhipu GLM) API key
GroqAPIKey string `json:"groq_api_key,omitempty"` // Groq API key
MistralAPIKey string `json:"mistral_api_key,omitempty"` // Mistral API key
CerebrasAPIKey string `json:"cerebras_api_key,omitempty"` // Cerebras API key
TogetherAPIKey string `json:"together_api_key,omitempty"` // Together AI API key
FireworksAPIKey string `json:"fireworks_api_key,omitempty"` // Fireworks AI API key
OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434)
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
OllamaPassword string `json:"ollama_password,omitempty"` // Optional Basic Auth password for Ollama
@ -116,6 +122,12 @@ const (
AIProviderOllama = "ollama"
AIProviderDeepSeek = "deepseek"
AIProviderGemini = "gemini"
AIProviderZai = "zai" // Z.ai OpenAI-compatible provider
AIProviderGroq = "groq"
AIProviderMistral = "mistral"
AIProviderCerebras = "cerebras"
AIProviderTogether = "together"
AIProviderFireworks = "fireworks"
AIProviderQuickstart = "quickstart" // Retired Pulse-hosted proxy marker retained only for legacy config migration.
)
@ -156,9 +168,15 @@ const (
DefaultAIModelQuickstart = "pulse-hosted"
DefaultOllamaBaseURL = "http://localhost:11434"
DefaultOllamaKeepAlive = "30s"
DefaultOpenRouterBaseURL = "https://openrouter.ai/api/v1/chat/completions"
DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions"
DefaultOpenRouterBaseURL = "https://openrouter.ai/api/v1"
DefaultDeepSeekBaseURL = "https://api.deepseek.com"
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta"
DefaultZaiBaseURL = "https://api.z.ai/api/paas/v4"
DefaultGroqBaseURL = "https://api.groq.com/openai/v1"
DefaultMistralBaseURL = "https://api.mistral.ai/v1"
DefaultCerebrasBaseURL = "https://api.cerebras.ai/v1"
DefaultTogetherBaseURL = "https://api.together.xyz/v1"
DefaultFireworksBaseURL = "https://api.fireworks.ai/inference/v1"
)
const (
@ -330,22 +348,18 @@ func (c *AIConfig) GetOllamaKeepAlive() string {
// IsConfigured returns true if the AI config has enough info to make API calls
// For multi-provider setup, returns true if ANY provider is configured
func (c *AIConfig) IsConfigured() bool {
if !c.Enabled {
if c == nil || !c.Enabled {
return false
}
if c.HasProvider(AIProviderAnthropic) || c.HasProvider(AIProviderOpenAI) ||
c.HasProvider(AIProviderOpenRouter) || c.HasProvider(AIProviderDeepSeek) || c.HasProvider(AIProviderOllama) ||
c.HasProvider(AIProviderGemini) {
return true
}
return false
return len(c.GetConfiguredProviders()) > 0
}
// HasProvider returns true if the specified provider has credentials configured
func (c *AIConfig) HasProvider(provider string) bool {
switch provider {
if c == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case AIProviderAnthropic:
return c.AnthropicAPIKey != ""
case AIProviderOpenAI:
@ -356,6 +370,18 @@ func (c *AIConfig) HasProvider(provider string) bool {
return c.DeepSeekAPIKey != ""
case AIProviderGemini:
return c.GeminiAPIKey != ""
case AIProviderZai:
return c.ZaiAPIKey != ""
case AIProviderGroq:
return c.GroqAPIKey != ""
case AIProviderMistral:
return c.MistralAPIKey != ""
case AIProviderCerebras:
return c.CerebrasAPIKey != ""
case AIProviderTogether:
return c.TogetherAPIKey != ""
case AIProviderFireworks:
return c.FireworksAPIKey != ""
case AIProviderOllama:
// Ollama is only "configured" if user has explicitly set a base URL
return c.OllamaBaseURL != ""
@ -366,31 +392,24 @@ func (c *AIConfig) HasProvider(provider string) bool {
// GetConfiguredProviders returns a list of all providers with credentials configured
func (c *AIConfig) GetConfiguredProviders() []string {
var providers []string
if c.HasProvider(AIProviderAnthropic) {
providers = append(providers, AIProviderAnthropic)
if c == nil {
return nil
}
if c.HasProvider(AIProviderOpenAI) {
providers = append(providers, AIProviderOpenAI)
var configured []string
for _, def := range AIConfigurableProviderDefinitions() {
if c.HasProvider(def.ID) {
configured = append(configured, def.ID)
}
}
if c.HasProvider(AIProviderOpenRouter) {
providers = append(providers, AIProviderOpenRouter)
}
if c.HasProvider(AIProviderDeepSeek) {
providers = append(providers, AIProviderDeepSeek)
}
if c.HasProvider(AIProviderGemini) {
providers = append(providers, AIProviderGemini)
}
if c.HasProvider(AIProviderOllama) {
providers = append(providers, AIProviderOllama)
}
return providers
return configured
}
// GetAPIKeyForProvider returns the API key for the specified provider
func (c *AIConfig) GetAPIKeyForProvider(provider string) string {
switch provider {
if c == nil {
return ""
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case AIProviderAnthropic:
if c.AnthropicAPIKey != "" {
return c.AnthropicAPIKey
@ -411,29 +430,51 @@ func (c *AIConfig) GetAPIKeyForProvider(provider string) string {
if c.GeminiAPIKey != "" {
return c.GeminiAPIKey
}
case AIProviderZai:
if c.ZaiAPIKey != "" {
return c.ZaiAPIKey
}
case AIProviderGroq:
if c.GroqAPIKey != "" {
return c.GroqAPIKey
}
case AIProviderMistral:
if c.MistralAPIKey != "" {
return c.MistralAPIKey
}
case AIProviderCerebras:
if c.CerebrasAPIKey != "" {
return c.CerebrasAPIKey
}
case AIProviderTogether:
if c.TogetherAPIKey != "" {
return c.TogetherAPIKey
}
case AIProviderFireworks:
if c.FireworksAPIKey != "" {
return c.FireworksAPIKey
}
}
return ""
}
// GetBaseURLForProvider returns the base URL for the specified provider
func (c *AIConfig) GetBaseURLForProvider(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
switch provider {
case AIProviderOllama:
if c.OllamaBaseURL != "" {
if c != nil && c.OllamaBaseURL != "" {
return c.OllamaBaseURL
}
return DefaultOllamaBaseURL
case AIProviderOpenAI:
if c.OpenAIBaseURL != "" {
if c != nil && c.OpenAIBaseURL != "" {
return c.OpenAIBaseURL
}
return "" // Uses default OpenAI URL
case AIProviderOpenRouter:
return DefaultOpenRouterBaseURL
case AIProviderDeepSeek:
return DefaultDeepSeekBaseURL
case AIProviderGemini:
return DefaultGeminiBaseURL
}
if def, ok := LookupAIProviderDefinition(provider); ok {
return def.DefaultBaseURL
}
return ""
}
@ -447,8 +488,10 @@ func (c *AIConfig) IsUsingOAuth() bool {
// ParseModelString parses a model string in "provider:model-name" format
// Returns the provider and model name. If no provider prefix, attempts to detect.
func ParseModelString(model string) (provider, modelName string) {
model = strings.TrimSpace(model)
// Check for explicit provider prefix
for _, p := range []string{AIProviderAnthropic, AIProviderOpenAI, AIProviderOpenRouter, AIProviderDeepSeek, AIProviderGemini, AIProviderOllama, AIProviderQuickstart} {
for _, def := range AIProviderDefinitions() {
p := def.ID
prefix := p + ":"
if len(model) > len(prefix) && model[:len(prefix)] == prefix {
return p, model[len(prefix):]
@ -501,24 +544,11 @@ func NormalizeQuickstartModelString(model string) string {
// DefaultModelForProvider returns the default "provider:model" string for a given provider name.
// Returns empty string if the provider is unknown.
func DefaultModelForProvider(provider string) string {
switch provider {
case AIProviderAnthropic:
return FormatModelString(AIProviderAnthropic, "claude-3-5-sonnet-latest")
case AIProviderOpenAI:
return FormatModelString(AIProviderOpenAI, "gpt-4o")
case AIProviderOpenRouter:
return FormatModelString(AIProviderOpenRouter, "openai/gpt-4o-mini")
case AIProviderDeepSeek:
return FormatModelString(AIProviderDeepSeek, DeepSeekModelV4Flash)
case AIProviderGemini:
return FormatModelString(AIProviderGemini, "gemini-1.5-pro")
case AIProviderOllama:
return FormatModelString(AIProviderOllama, "llama3.2")
case AIProviderQuickstart:
return ""
default:
def, ok := LookupAIProviderDefinition(provider)
if !ok || def.DefaultModel == "" || def.Protocol == AIProviderProtocolRetired {
return ""
}
return FormatModelString(def.ID, def.DefaultModel)
}
// GetModel returns the explicitly configured model, if any.

View file

@ -143,6 +143,54 @@ func TestAIConfig_IsConfigured(t *testing.T) {
},
expected: true,
},
{
name: "enabled with zai key",
config: AIConfig{
Enabled: true,
ZaiAPIKey: "sk-zai-123",
},
expected: true,
},
{
name: "enabled with groq key",
config: AIConfig{
Enabled: true,
GroqAPIKey: "gsk-123",
},
expected: true,
},
{
name: "enabled with mistral key",
config: AIConfig{
Enabled: true,
MistralAPIKey: "mistral-123",
},
expected: true,
},
{
name: "enabled with cerebras key",
config: AIConfig{
Enabled: true,
CerebrasAPIKey: "cerebras-123",
},
expected: true,
},
{
name: "enabled with together key",
config: AIConfig{
Enabled: true,
TogetherAPIKey: "together-123",
},
expected: true,
},
{
name: "enabled with fireworks key",
config: AIConfig{
Enabled: true,
FireworksAPIKey: "fireworks-123",
},
expected: true,
},
{
name: "enabled with ollama but no URL configured",
config: AIConfig{
@ -222,6 +270,42 @@ func TestAIConfig_HasProvider(t *testing.T) {
provider: AIProviderGemini,
expected: true,
},
{
name: "zai configured",
config: AIConfig{ZaiAPIKey: "key"},
provider: AIProviderZai,
expected: true,
},
{
name: "groq configured",
config: AIConfig{GroqAPIKey: "key"},
provider: AIProviderGroq,
expected: true,
},
{
name: "mistral configured",
config: AIConfig{MistralAPIKey: "key"},
provider: AIProviderMistral,
expected: true,
},
{
name: "cerebras configured",
config: AIConfig{CerebrasAPIKey: "key"},
provider: AIProviderCerebras,
expected: true,
},
{
name: "together configured",
config: AIConfig{TogetherAPIKey: "key"},
provider: AIProviderTogether,
expected: true,
},
{
name: "fireworks configured",
config: AIConfig{FireworksAPIKey: "key"},
provider: AIProviderFireworks,
expected: true,
},
{
name: "ollama configured",
config: AIConfig{OllamaBaseURL: "http://localhost:11434"},
@ -256,6 +340,12 @@ func TestDefaultModelForProvider_UsesCanonicalProviderFallbacks(t *testing.T) {
{provider: AIProviderOpenRouter, expected: "openrouter:openai/gpt-4o-mini"},
{provider: AIProviderDeepSeek, expected: "deepseek:deepseek-v4-flash"},
{provider: AIProviderGemini, expected: "gemini:gemini-1.5-pro"},
{provider: AIProviderZai, expected: "zai:glm-5.2"},
{provider: AIProviderGroq, expected: "groq:llama-3.3-70b-versatile"},
{provider: AIProviderMistral, expected: "mistral:mistral-large-latest"},
{provider: AIProviderCerebras, expected: "cerebras:llama-4-scout-17b-16e-instruct"},
{provider: AIProviderTogether, expected: "together:meta-llama/Llama-3.3-70B-Instruct-Turbo"},
{provider: AIProviderFireworks, expected: "fireworks:accounts/fireworks/models/llama-v3p1-70b-instruct"},
{provider: AIProviderOllama, expected: "ollama:llama3.2"},
{provider: AIProviderQuickstart, expected: ""},
{provider: "unknown", expected: ""},
@ -270,6 +360,70 @@ func TestDefaultModelForProvider_UsesCanonicalProviderFallbacks(t *testing.T) {
}
}
func TestAIProviderDefinitions_CanonicalDirectProviderRegistry(t *testing.T) {
defs := AIConfigurableProviderDefinitions()
gotIDs := make([]string, 0, len(defs))
for _, def := range defs {
gotIDs = append(gotIDs, def.ID)
}
wantIDs := []string{
AIProviderAnthropic,
AIProviderOpenAI,
AIProviderOpenRouter,
AIProviderDeepSeek,
AIProviderGemini,
AIProviderZai,
AIProviderGroq,
AIProviderMistral,
AIProviderCerebras,
AIProviderTogether,
AIProviderFireworks,
AIProviderOllama,
}
if len(gotIDs) != len(wantIDs) {
t.Fatalf("AIConfigurableProviderDefinitions returned %d providers %v, want %d %v", len(gotIDs), gotIDs, len(wantIDs), wantIDs)
}
for i, want := range wantIDs {
if gotIDs[i] != want {
t.Fatalf("provider order mismatch at %d: got %q in %v, want %q in %v", i, gotIDs[i], gotIDs, want, wantIDs)
}
}
for _, provider := range []string{
AIProviderOpenRouter,
AIProviderDeepSeek,
AIProviderZai,
AIProviderGroq,
AIProviderMistral,
AIProviderCerebras,
AIProviderTogether,
AIProviderFireworks,
} {
def, ok := LookupAIProviderDefinition(provider)
if !ok {
t.Fatalf("missing provider definition for %q", provider)
}
if def.Protocol != AIProviderProtocolOpenAICompatible {
t.Fatalf("%s protocol = %q, want %q", provider, def.Protocol, AIProviderProtocolOpenAICompatible)
}
if def.DefaultBaseURL == "" {
t.Fatalf("%s must declare a default base URL", provider)
}
if def.APIKeyField == "" || def.ConfiguredField == "" || def.ClearKeyField == "" {
t.Fatalf("%s must declare API settings fields: %#v", provider, def)
}
}
if _, ok := LookupAIProviderDefinition(AIProviderQuickstart); !ok {
t.Fatalf("retired quickstart marker should remain known for migration cleanup")
}
for _, def := range AIConfigurableProviderDefinitions() {
if def.ID == AIProviderQuickstart {
t.Fatalf("retired quickstart marker must not be user configurable")
}
}
}
func TestDeepSeekModelCatalogHelpers(t *testing.T) {
if got := DeepSeekV4ModelIDs(); len(got) != 2 || got[0] != DeepSeekModelV4Flash || got[1] != DeepSeekModelV4Pro {
t.Fatalf("DeepSeekV4ModelIDs() = %#v", got)
@ -347,6 +501,12 @@ func TestAIConfig_GetAPIKeyForProvider(t *testing.T) {
OpenRouterAPIKey: "openrouter-key",
DeepSeekAPIKey: "deepseek-key",
GeminiAPIKey: "gemini-key",
ZaiAPIKey: "zai-key",
GroqAPIKey: "groq-key",
MistralAPIKey: "mistral-key",
CerebrasAPIKey: "cerebras-key",
TogetherAPIKey: "together-key",
FireworksAPIKey: "fireworks-key",
}
tests := []struct {
@ -358,6 +518,12 @@ func TestAIConfig_GetAPIKeyForProvider(t *testing.T) {
{AIProviderOpenRouter, "openrouter-key"},
{AIProviderDeepSeek, "deepseek-key"},
{AIProviderGemini, "gemini-key"},
{AIProviderZai, "zai-key"},
{AIProviderGroq, "groq-key"},
{AIProviderMistral, "mistral-key"},
{AIProviderCerebras, "cerebras-key"},
{AIProviderTogether, "together-key"},
{AIProviderFireworks, "fireworks-key"},
{AIProviderOllama, ""},
{"unknown", ""},
}
@ -387,6 +553,12 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
{AIProviderOpenRouter, DefaultOpenRouterBaseURL},
{AIProviderDeepSeek, DefaultDeepSeekBaseURL},
{AIProviderGemini, DefaultGeminiBaseURL},
{AIProviderZai, DefaultZaiBaseURL},
{AIProviderGroq, DefaultGroqBaseURL},
{AIProviderMistral, DefaultMistralBaseURL},
{AIProviderCerebras, DefaultCerebrasBaseURL},
{AIProviderTogether, DefaultTogetherBaseURL},
{AIProviderFireworks, DefaultFireworksBaseURL},
{AIProviderAnthropic, ""},
{"unknown", ""},
}
@ -501,6 +673,12 @@ func TestParseModelString(t *testing.T) {
{"ollama:llama3", AIProviderOllama, "llama3"},
{"deepseek:deepseek-chat", AIProviderDeepSeek, "deepseek-chat"},
{"gemini:gemini-1.5-pro", AIProviderGemini, "gemini-1.5-pro"},
{"zai:glm-5.2", AIProviderZai, "glm-5.2"},
{"groq:llama-3.3-70b-versatile", AIProviderGroq, "llama-3.3-70b-versatile"},
{"mistral:mistral-large-latest", AIProviderMistral, "mistral-large-latest"},
{"cerebras:llama-4-scout-17b-16e-instruct", AIProviderCerebras, "llama-4-scout-17b-16e-instruct"},
{"together:meta-llama/Llama-3.3-70B-Instruct-Turbo", AIProviderTogether, "meta-llama/Llama-3.3-70B-Instruct-Turbo"},
{"fireworks:accounts/fireworks/models/llama-v3p1-70b-instruct", AIProviderFireworks, "accounts/fireworks/models/llama-v3p1-70b-instruct"},
// Detection by name
{"claude-3-opus", AIProviderAnthropic, "claude-3-opus"},
{"gpt-4o", AIProviderOpenAI, "gpt-4o"},

View file

@ -0,0 +1,342 @@
package config
import "strings"
// AIProviderProtocol identifies the transport/runtime shape a provider uses.
type AIProviderProtocol string
const (
AIProviderProtocolAnthropic AIProviderProtocol = "anthropic"
AIProviderProtocolOpenAICompatible AIProviderProtocol = "openai_compatible"
AIProviderProtocolGemini AIProviderProtocol = "gemini"
AIProviderProtocolOllama AIProviderProtocol = "ollama"
AIProviderProtocolRetired AIProviderProtocol = "retired"
)
// AIProviderModelDefinition is the config-layer representation of catalog
// models that Pulse should present even when a provider's /models endpoint is
// absent, incomplete, or temporarily unavailable.
type AIProviderModelDefinition struct {
ID string
Name string
Description string
Notable bool
}
// AIProviderDefinition is the canonical internal registry record for provider
// metadata and runtime capability selection.
type AIProviderDefinition struct {
ID string
DisplayName string
Description string
Protocol AIProviderProtocol
DefaultModel string
DefaultBaseURL string
APIKeyField string
ConfiguredField string
ClearKeyField string
BaseURLField string
RequiresAPIKey bool
UserConfigurable bool
Gateway bool
ModelsDevProviderID string
EnvVars []string
DocsURL string
FallbackModels []AIProviderModelDefinition
}
func aiProviderDefinitions() []AIProviderDefinition {
return []AIProviderDefinition{
{
ID: AIProviderAnthropic,
DisplayName: "Anthropic",
Description: "Claude models from Anthropic",
Protocol: AIProviderProtocolAnthropic,
DefaultModel: "claude-3-5-sonnet-latest",
APIKeyField: "anthropic_api_key",
ConfiguredField: "anthropic_configured",
ClearKeyField: "clear_anthropic_key",
RequiresAPIKey: true,
UserConfigurable: true,
EnvVars: []string{"ANTHROPIC_API_KEY"},
DocsURL: "https://docs.anthropic.com/en/api/getting-started",
},
{
ID: AIProviderOpenAI,
DisplayName: "OpenAI",
Description: "GPT and reasoning models from OpenAI, or a custom OpenAI-compatible endpoint",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "gpt-4o",
APIKeyField: "openai_api_key",
ConfiguredField: "openai_configured",
ClearKeyField: "clear_openai_key",
BaseURLField: "openai_base_url",
RequiresAPIKey: true,
UserConfigurable: true,
EnvVars: []string{"OPENAI_API_KEY"},
DocsURL: "https://platform.openai.com/docs/api-reference/chat",
},
{
ID: AIProviderOpenRouter,
DisplayName: "OpenRouter",
Description: "Gateway for OpenAI-compatible hosted models",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "openai/gpt-4o-mini",
DefaultBaseURL: DefaultOpenRouterBaseURL,
APIKeyField: "openrouter_api_key",
ConfiguredField: "openrouter_configured",
ClearKeyField: "clear_openrouter_key",
RequiresAPIKey: true,
UserConfigurable: true,
Gateway: true,
ModelsDevProviderID: "openrouter",
EnvVars: []string{"OPENROUTER_API_KEY"},
DocsURL: "https://openrouter.ai/docs",
},
{
ID: AIProviderDeepSeek,
DisplayName: "DeepSeek",
Description: "DeepSeek direct API models",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: DeepSeekModelV4Flash,
DefaultBaseURL: DefaultDeepSeekBaseURL,
APIKeyField: "deepseek_api_key",
ConfiguredField: "deepseek_configured",
ClearKeyField: "clear_deepseek_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "deepseek",
EnvVars: []string{"DEEPSEEK_API_KEY"},
DocsURL: "https://api-docs.deepseek.com/",
FallbackModels: []AIProviderModelDefinition{
{
ID: DeepSeekModelV4Flash,
Name: "DeepSeek V4 Flash",
Description: "DeepSeek: current V4 Flash direct API model",
Notable: true,
},
{
ID: DeepSeekModelV4Pro,
Name: "DeepSeek V4 Pro",
Description: "DeepSeek: current V4 Pro direct API model",
Notable: true,
},
{
ID: DeepSeekModelLegacyChat,
Name: "DeepSeek Chat (legacy alias)",
Description: "DeepSeek: legacy alias currently routing to V4 Flash",
},
{
ID: DeepSeekModelLegacyReasoner,
Name: "DeepSeek Reasoner (legacy alias)",
Description: "DeepSeek: legacy alias currently routing to V4 Flash",
},
},
},
{
ID: AIProviderGemini,
DisplayName: "Google Gemini",
Description: "Gemini models from Google",
Protocol: AIProviderProtocolGemini,
DefaultModel: "gemini-1.5-pro",
DefaultBaseURL: DefaultGeminiBaseURL,
APIKeyField: "gemini_api_key",
ConfiguredField: "gemini_configured",
ClearKeyField: "clear_gemini_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "google",
EnvVars: []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"},
DocsURL: "https://ai.google.dev/gemini-api/docs",
},
{
ID: AIProviderZai,
DisplayName: "Z.ai",
Description: "GLM models from Z.ai using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "glm-5.2",
DefaultBaseURL: DefaultZaiBaseURL,
APIKeyField: "zai_api_key",
ConfiguredField: "zai_configured",
ClearKeyField: "clear_zai_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "zai",
EnvVars: []string{"ZHIPU_API_KEY", "ZAI_API_KEY"},
DocsURL: "https://docs.z.ai/guides/develop/openai/python",
FallbackModels: []AIProviderModelDefinition{
{
ID: "glm-5.2",
Name: "GLM 5.2",
Description: "Z.ai: GLM 5.2 direct API model",
Notable: true,
},
},
},
{
ID: AIProviderGroq,
DisplayName: "Groq",
Description: "Groq hosted models using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "llama-3.3-70b-versatile",
DefaultBaseURL: DefaultGroqBaseURL,
APIKeyField: "groq_api_key",
ConfiguredField: "groq_configured",
ClearKeyField: "clear_groq_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "groq",
EnvVars: []string{"GROQ_API_KEY"},
DocsURL: "https://console.groq.com/docs/openai",
},
{
ID: AIProviderMistral,
DisplayName: "Mistral",
Description: "Mistral models using the OpenAI-compatible chat API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "mistral-large-latest",
DefaultBaseURL: DefaultMistralBaseURL,
APIKeyField: "mistral_api_key",
ConfiguredField: "mistral_configured",
ClearKeyField: "clear_mistral_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "mistral",
EnvVars: []string{"MISTRAL_API_KEY"},
DocsURL: "https://docs.mistral.ai/resources/migration-guides",
},
{
ID: AIProviderCerebras,
DisplayName: "Cerebras",
Description: "Cerebras Inference models using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "llama-4-scout-17b-16e-instruct",
DefaultBaseURL: DefaultCerebrasBaseURL,
APIKeyField: "cerebras_api_key",
ConfiguredField: "cerebras_configured",
ClearKeyField: "clear_cerebras_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "cerebras",
EnvVars: []string{"CEREBRAS_API_KEY"},
DocsURL: "https://inference-docs.cerebras.ai/resources/openai",
},
{
ID: AIProviderTogether,
DisplayName: "Together AI",
Description: "Together AI models using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
DefaultBaseURL: DefaultTogetherBaseURL,
APIKeyField: "together_api_key",
ConfiguredField: "together_configured",
ClearKeyField: "clear_together_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "togetherai",
EnvVars: []string{"TOGETHER_API_KEY"},
DocsURL: "https://docs.together.ai/docs/inference/openai-compatibility",
},
{
ID: AIProviderFireworks,
DisplayName: "Fireworks AI",
Description: "Fireworks models using the OpenAI-compatible API",
Protocol: AIProviderProtocolOpenAICompatible,
DefaultModel: "accounts/fireworks/models/llama-v3p1-70b-instruct",
DefaultBaseURL: DefaultFireworksBaseURL,
APIKeyField: "fireworks_api_key",
ConfiguredField: "fireworks_configured",
ClearKeyField: "clear_fireworks_key",
RequiresAPIKey: true,
UserConfigurable: true,
ModelsDevProviderID: "fireworks-ai",
EnvVars: []string{"FIREWORKS_API_KEY"},
DocsURL: "https://docs.fireworks.ai/tools-sdks/openai-compatibility",
},
{
ID: AIProviderOllama,
DisplayName: "Ollama",
Description: "Local models served by Ollama",
Protocol: AIProviderProtocolOllama,
DefaultModel: "llama3.2",
DefaultBaseURL: DefaultOllamaBaseURL,
ConfiguredField: "ollama_configured",
ClearKeyField: "clear_ollama_url",
BaseURLField: "ollama_base_url",
UserConfigurable: true,
DocsURL: "https://ollama.com",
},
{
ID: AIProviderQuickstart,
DisplayName: "Pulse hosted quickstart",
Description: "Retired pre-GA hosted proxy marker retained for migration cleanup",
Protocol: AIProviderProtocolRetired,
DefaultModel: "",
ConfiguredField: "quickstart_configured",
},
}
}
// AIProviderDefinitions returns the ordered provider registry. The slice and
// nested slices are copies so callers cannot mutate the canonical records.
func AIProviderDefinitions() []AIProviderDefinition {
defs := aiProviderDefinitions()
for i := range defs {
defs[i].EnvVars = append([]string(nil), defs[i].EnvVars...)
defs[i].FallbackModels = append([]AIProviderModelDefinition(nil), defs[i].FallbackModels...)
}
return defs
}
// AIConfigurableProviderDefinitions returns user-configurable providers in UI
// and model-list order.
func AIConfigurableProviderDefinitions() []AIProviderDefinition {
defs := AIProviderDefinitions()
out := make([]AIProviderDefinition, 0, len(defs))
for _, def := range defs {
if def.UserConfigurable {
out = append(out, def)
}
}
return out
}
func LookupAIProviderDefinition(provider string) (AIProviderDefinition, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return AIProviderDefinition{}, false
}
for _, def := range aiProviderDefinitions() {
if def.ID == provider {
def.EnvVars = append([]string(nil), def.EnvVars...)
def.FallbackModels = append([]AIProviderModelDefinition(nil), def.FallbackModels...)
return def, true
}
}
return AIProviderDefinition{}, false
}
func IsKnownAIProvider(provider string) bool {
_, ok := LookupAIProviderDefinition(provider)
return ok
}
func IsOpenAICompatibleProvider(provider string) bool {
def, ok := LookupAIProviderDefinition(provider)
return ok && def.Protocol == AIProviderProtocolOpenAICompatible
}
func AIProviderFallbackModels(provider string) []AIProviderModelDefinition {
def, ok := LookupAIProviderDefinition(provider)
if !ok || len(def.FallbackModels) == 0 {
return nil
}
return append([]AIProviderModelDefinition(nil), def.FallbackModels...)
}
func AIProviderDisplayName(provider string) string {
if def, ok := LookupAIProviderDefinition(provider); ok && def.DisplayName != "" {
return def.DisplayName
}
return provider
}

View file

@ -2915,7 +2915,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1047,
"line": 1059,
"heading_line": 136,
}
],