diff --git a/cmd/pulse-mcp/README.md b/cmd/pulse-mcp/README.md index 1c963c63df..1fe8876a1d 100644 --- a/cmd/pulse-mcp/README.md +++ b/cmd/pulse-mcp/README.md @@ -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):** diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f635530fa5..5129165302 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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: diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index ff6a7a4c9d..c3da62dfc8 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index d87ea8359c..be88ef6da4 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2b1529447e..7de1fb59d3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bc11d224c8..6f974351f4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/public/docs/README.md b/frontend-modern/public/docs/README.md index b8b0bc5048..7e95c22289 100644 --- a/frontend-modern/public/docs/README.md +++ b/frontend-modern/public/docs/README.md @@ -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)** diff --git a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx index ca6a1b599f..c764ef7a99 100644 --- a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx @@ -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( diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 77a3d6f335..829925dd95 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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'"); diff --git a/frontend-modern/src/components/Settings/aiSettingsModel.ts b/frontend-modern/src/components/Settings/aiSettingsModel.ts index 7c94d7ca4b..1ccc3d5a52 100644 --- a/frontend-modern/src/components/Settings/aiSettingsModel.ts +++ b/frontend-modern/src/components/Settings/aiSettingsModel.ts @@ -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[] = [ { 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 = { 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', ''); diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index 2ac608885f..9d2dd2ee5a 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -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 = { 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 diff --git a/frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts index 6cbfe32f4c..22b98d9fe8 100644 --- a/frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts @@ -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({ diff --git a/frontend-modern/src/utils/aiProviderPresentation.ts b/frontend-modern/src/utils/aiProviderPresentation.ts index 39ed8509b2..b2cd137d0e 100644 --- a/frontend-modern/src/utils/aiProviderPresentation.ts +++ b/frontend-modern/src/utils/aiProviderPresentation.ts @@ -6,6 +6,12 @@ export const AI_PROVIDER_DISPLAY_NAMES: Record = { 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 = { 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 ''; diff --git a/internal/ai/patrol_readiness.go b/internal/ai/patrol_readiness.go index 93e11731cd..5ddf9a15ed 100644 --- a/internal/ai/patrol_readiness.go +++ b/internal/ai/patrol_readiness.go @@ -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 { diff --git a/internal/ai/patrol_reconcile_test.go b/internal/ai/patrol_reconcile_test.go index ea91c14c45..92e5cdd571 100644 --- a/internal/ai/patrol_reconcile_test.go +++ b/internal/ai/patrol_reconcile_test.go @@ -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 diff --git a/internal/ai/providers/factory.go b/internal/ai/providers/factory.go index 3f4a8cd9f9..c2694ab8ef 100644 --- a/internal/ai/providers/factory.go +++ b/internal/ai/providers/factory.go @@ -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) diff --git a/internal/ai/providers/factory_test.go b/internal/ai/providers/factory_test.go index 9e23d9e106..5c4cedec10 100644 --- a/internal/ai/providers/factory_test.go +++ b/internal/ai/providers/factory_test.go @@ -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, diff --git a/internal/ai/providers/openai.go b/internal/ai/providers/openai.go index fa69be1897..29e673c701 100644 --- a/internal/ai/providers/openai.go +++ b/internal/ai/providers/openai.go @@ -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") || diff --git a/internal/ai/providers/openai_test.go b/internal/ai/providers/openai_test.go index bdd4aadeb3..e93019b369 100644 --- a/internal/ai/providers/openai_test.go +++ b/internal/ai/providers/openai_test.go @@ -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) diff --git a/internal/ai/service.go b/internal/ai/service.go index c87aa047dc..a9418e8b87 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -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) diff --git a/internal/api/ai_handler_test.go b/internal/api/ai_handler_test.go index 263f76e75c..3b1da9bb23 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -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) }) diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 9723e3a31f..5ec271343f 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -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(), diff --git a/internal/api/ai_handlers_patrol_actions_additional_test.go b/internal/api/ai_handlers_patrol_actions_additional_test.go index 8e06a1f915..044aec9854 100644 --- a/internal/api/ai_handlers_patrol_actions_additional_test.go +++ b/internal/api/ai_handlers_patrol_actions_additional_test.go @@ -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) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 43629afdb3..53f7e92f38 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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) { diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index a228bb117e..91d8d7a296 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -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", diff --git a/internal/config/ai.go b/internal/config/ai.go index 67fdc97f98..a1184ee839 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -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. diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go index 95784329e2..e20cd1043b 100644 --- a/internal/config/ai_config_test.go +++ b/internal/config/ai_config_test.go @@ -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"}, diff --git a/internal/config/ai_providers.go b/internal/config/ai_providers.go new file mode 100644 index 0000000000..d9981e2105 --- /dev/null +++ b/internal/config/ai_providers.go @@ -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 +} diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 5c0bf07e62..75750a7095 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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, } ],