mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
Canonicalize assistant provider registry
Add a registry-backed provider model for runtime metadata and chat-compatible transports. Support Z.ai, Groq, Mistral, Cerebras, Together, and Fireworks through the shared chat-compatible client path while keeping native providers on their existing adapters. Expose provider metadata through settings and render the expanded provider set in the frontend.
This commit is contained in:
parent
da0693b3b1
commit
09fc029bd6
30 changed files with 1633 additions and 224 deletions
|
|
@ -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):**
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ export type AIProviderCredentialsFormState = {
|
|||
openaiApiKey: string;
|
||||
openrouterApiKey: string;
|
||||
deepseekApiKey: string;
|
||||
zaiApiKey: string;
|
||||
groqApiKey: string;
|
||||
mistralApiKey: string;
|
||||
cerebrasApiKey: string;
|
||||
togetherApiKey: string;
|
||||
fireworksApiKey: string;
|
||||
geminiApiKey: string;
|
||||
ollamaBaseUrl: string;
|
||||
ollamaKeepAlive: string;
|
||||
|
|
@ -62,6 +68,12 @@ export const AI_PROVIDERS: AIProvider[] = [
|
|||
'openrouter',
|
||||
'deepseek',
|
||||
'gemini',
|
||||
'zai',
|
||||
'groq',
|
||||
'mistral',
|
||||
'cerebras',
|
||||
'together',
|
||||
'fireworks',
|
||||
'ollama',
|
||||
];
|
||||
|
||||
|
|
@ -71,6 +83,12 @@ export const AI_SETUP_PROVIDER_OPTIONS: SelectionCardOption<AIProvider>[] = [
|
|||
{ value: 'openrouter', title: 'OpenRouter', description: 'Gateway' },
|
||||
{ value: 'deepseek', title: 'DeepSeek', description: 'V4' },
|
||||
{ value: 'gemini', title: 'Gemini', description: 'Google' },
|
||||
{ value: 'zai', title: 'Z.ai', description: 'GLM' },
|
||||
{ value: 'groq', title: 'Groq', description: 'Fast' },
|
||||
{ value: 'mistral', title: 'Mistral', description: 'Models' },
|
||||
{ value: 'cerebras', title: 'Cerebras', description: 'Inference' },
|
||||
{ value: 'together', title: 'Together', description: 'Open models' },
|
||||
{ value: 'fireworks', title: 'Fireworks', description: 'Open models' },
|
||||
{ value: 'ollama', title: 'Ollama', description: 'Local' },
|
||||
];
|
||||
|
||||
|
|
@ -133,6 +151,84 @@ export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
|
|||
actionLinkHref: 'https://platform.deepseek.com/api_keys',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'zai',
|
||||
title: 'Z.ai',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'zaiApiKey',
|
||||
placeholder: 'Z.AI API key',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Open docs →',
|
||||
actionLinkHref: 'https://docs.z.ai/guides/develop/openai/python',
|
||||
helperText: 'Uses https://api.z.ai/api/paas/v4 automatically.',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'groq',
|
||||
title: 'Groq',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'groqApiKey',
|
||||
placeholder: 'gsk_...',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://console.groq.com/keys',
|
||||
helperText: 'Uses https://api.groq.com/openai/v1 automatically.',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'mistral',
|
||||
title: 'Mistral',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'mistralApiKey',
|
||||
placeholder: 'Mistral API key',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://console.mistral.ai/api-keys',
|
||||
helperText: 'Uses https://api.mistral.ai/v1 automatically.',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'cerebras',
|
||||
title: 'Cerebras',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'cerebrasApiKey',
|
||||
placeholder: 'Cerebras API key',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Open docs →',
|
||||
actionLinkHref: 'https://inference-docs.cerebras.ai/resources/openai',
|
||||
helperText: 'Uses https://api.cerebras.ai/v1 automatically.',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'together',
|
||||
title: 'Together AI',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'togetherApiKey',
|
||||
placeholder: 'Together API key',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://api.together.ai/settings/api-keys',
|
||||
helperText: 'Uses https://api.together.xyz/v1 automatically.',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'fireworks',
|
||||
title: 'Fireworks AI',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'fireworksApiKey',
|
||||
placeholder: 'Fireworks API key',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://fireworks.ai/account/api-keys',
|
||||
helperText: 'Uses https://api.fireworks.ai/inference/v1 automatically.',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'gemini',
|
||||
title: 'Google Gemini',
|
||||
|
|
@ -175,6 +271,12 @@ export const createInitialProviderHealth = (): Record<AIProvider, ProviderHealth
|
|||
openrouter: { status: 'not_configured', message: '' },
|
||||
deepseek: { status: 'not_configured', message: '' },
|
||||
gemini: { status: 'not_configured', message: '' },
|
||||
zai: { status: 'not_configured', message: '' },
|
||||
groq: { status: 'not_configured', message: '' },
|
||||
mistral: { status: 'not_configured', message: '' },
|
||||
cerebras: { status: 'not_configured', message: '' },
|
||||
together: { status: 'not_configured', message: '' },
|
||||
fireworks: { status: 'not_configured', message: '' },
|
||||
ollama: { status: 'not_configured', message: '' },
|
||||
});
|
||||
|
||||
|
|
@ -202,6 +304,18 @@ export function isAIProviderConfigured(
|
|||
return settings.deepseek_configured;
|
||||
case 'gemini':
|
||||
return settings.gemini_configured;
|
||||
case 'zai':
|
||||
return Boolean(settings.zai_configured);
|
||||
case 'groq':
|
||||
return Boolean(settings.groq_configured);
|
||||
case 'mistral':
|
||||
return Boolean(settings.mistral_configured);
|
||||
case 'cerebras':
|
||||
return Boolean(settings.cerebras_configured);
|
||||
case 'together':
|
||||
return Boolean(settings.together_configured);
|
||||
case 'fireworks':
|
||||
return Boolean(settings.fireworks_configured);
|
||||
case 'ollama':
|
||||
return settings.ollama_configured;
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ const AI_SETTINGS_PROVIDER_PAYLOAD_FIELDS: Record<AIProvider, string[]> = {
|
|||
openai: ['openai_api_key', 'openai_base_url'],
|
||||
openrouter: ['openrouter_api_key'],
|
||||
deepseek: ['deepseek_api_key'],
|
||||
zai: ['zai_api_key'],
|
||||
groq: ['groq_api_key'],
|
||||
mistral: ['mistral_api_key'],
|
||||
cerebras: ['cerebras_api_key'],
|
||||
together: ['together_api_key'],
|
||||
fireworks: ['fireworks_api_key'],
|
||||
gemini: ['gemini_api_key'],
|
||||
ollama: ['ollama_base_url', 'ollama_keep_alive'],
|
||||
};
|
||||
|
|
@ -306,6 +312,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
openaiApiKey: '',
|
||||
openrouterApiKey: '',
|
||||
deepseekApiKey: '',
|
||||
zaiApiKey: '',
|
||||
groqApiKey: '',
|
||||
mistralApiKey: '',
|
||||
cerebrasApiKey: '',
|
||||
togetherApiKey: '',
|
||||
fireworksApiKey: '',
|
||||
geminiApiKey: '',
|
||||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
ollamaKeepAlive: '30s',
|
||||
|
|
@ -345,6 +357,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
current.openai_configured ||
|
||||
current.openrouter_configured ||
|
||||
current.deepseek_configured ||
|
||||
current.zai_configured ||
|
||||
current.groq_configured ||
|
||||
current.mistral_configured ||
|
||||
current.cerebras_configured ||
|
||||
current.together_configured ||
|
||||
current.fireworks_configured ||
|
||||
current.gemini_configured ||
|
||||
current.ollama_configured),
|
||||
);
|
||||
|
|
@ -385,6 +403,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
openaiApiKey: '',
|
||||
openrouterApiKey: '',
|
||||
deepseekApiKey: '',
|
||||
zaiApiKey: '',
|
||||
groqApiKey: '',
|
||||
mistralApiKey: '',
|
||||
cerebrasApiKey: '',
|
||||
togetherApiKey: '',
|
||||
fireworksApiKey: '',
|
||||
geminiApiKey: '',
|
||||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
ollamaKeepAlive: '30s',
|
||||
|
|
@ -421,6 +445,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
openaiApiKey: '',
|
||||
openrouterApiKey: '',
|
||||
deepseekApiKey: '',
|
||||
zaiApiKey: '',
|
||||
groqApiKey: '',
|
||||
mistralApiKey: '',
|
||||
cerebrasApiKey: '',
|
||||
togetherApiKey: '',
|
||||
fireworksApiKey: '',
|
||||
geminiApiKey: '',
|
||||
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
|
||||
ollamaKeepAlive: data.ollama_keep_alive ?? '30s',
|
||||
|
|
@ -442,6 +472,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
if (data.openrouter_configured) configured.add('openrouter');
|
||||
if (data.deepseek_configured) configured.add('deepseek');
|
||||
if (data.gemini_configured) configured.add('gemini');
|
||||
if (data.zai_configured) configured.add('zai');
|
||||
if (data.groq_configured) configured.add('groq');
|
||||
if (data.mistral_configured) configured.add('mistral');
|
||||
if (data.cerebras_configured) configured.add('cerebras');
|
||||
if (data.together_configured) configured.add('together');
|
||||
if (data.fireworks_configured) configured.add('fireworks');
|
||||
if (data.ollama_configured) configured.add('ollama');
|
||||
if (configured.size === 0) configured.add('anthropic');
|
||||
setExpandedProviders(configured);
|
||||
|
|
@ -702,6 +738,42 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
return;
|
||||
}
|
||||
payload.deepseek_api_key = setupApiKey().trim();
|
||||
} else if (setupProvider() === 'zai') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your Z.ai API key');
|
||||
return;
|
||||
}
|
||||
payload.zai_api_key = setupApiKey().trim();
|
||||
} else if (setupProvider() === 'groq') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your Groq API key');
|
||||
return;
|
||||
}
|
||||
payload.groq_api_key = setupApiKey().trim();
|
||||
} else if (setupProvider() === 'mistral') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your Mistral API key');
|
||||
return;
|
||||
}
|
||||
payload.mistral_api_key = setupApiKey().trim();
|
||||
} else if (setupProvider() === 'cerebras') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your Cerebras API key');
|
||||
return;
|
||||
}
|
||||
payload.cerebras_api_key = setupApiKey().trim();
|
||||
} else if (setupProvider() === 'together') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your Together AI API key');
|
||||
return;
|
||||
}
|
||||
payload.together_api_key = setupApiKey().trim();
|
||||
} else if (setupProvider() === 'fireworks') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your Fireworks AI API key');
|
||||
return;
|
||||
}
|
||||
payload.fireworks_api_key = setupApiKey().trim();
|
||||
} else if (setupProvider() === 'gemini') {
|
||||
if (!setupApiKey().trim()) {
|
||||
notificationStore.error('Please enter your Google Gemini API key');
|
||||
|
|
@ -796,6 +868,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
(modelProvider === 'openai' && form.openaiApiKey.trim()) ||
|
||||
(modelProvider === 'openrouter' && form.openrouterApiKey.trim()) ||
|
||||
(modelProvider === 'deepseek' && form.deepseekApiKey.trim()) ||
|
||||
(modelProvider === 'zai' && form.zaiApiKey.trim()) ||
|
||||
(modelProvider === 'groq' && form.groqApiKey.trim()) ||
|
||||
(modelProvider === 'mistral' && form.mistralApiKey.trim()) ||
|
||||
(modelProvider === 'cerebras' && form.cerebrasApiKey.trim()) ||
|
||||
(modelProvider === 'together' && form.togetherApiKey.trim()) ||
|
||||
(modelProvider === 'fireworks' && form.fireworksApiKey.trim()) ||
|
||||
(modelProvider === 'gemini' && form.geminiApiKey.trim()) ||
|
||||
(modelProvider === 'ollama' && form.ollamaBaseUrl.trim());
|
||||
|
||||
|
|
@ -869,6 +947,24 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
if (form.deepseekApiKey.trim()) {
|
||||
payload.deepseek_api_key = form.deepseekApiKey.trim();
|
||||
}
|
||||
if (form.zaiApiKey.trim()) {
|
||||
payload.zai_api_key = form.zaiApiKey.trim();
|
||||
}
|
||||
if (form.groqApiKey.trim()) {
|
||||
payload.groq_api_key = form.groqApiKey.trim();
|
||||
}
|
||||
if (form.mistralApiKey.trim()) {
|
||||
payload.mistral_api_key = form.mistralApiKey.trim();
|
||||
}
|
||||
if (form.cerebrasApiKey.trim()) {
|
||||
payload.cerebras_api_key = form.cerebrasApiKey.trim();
|
||||
}
|
||||
if (form.togetherApiKey.trim()) {
|
||||
payload.together_api_key = form.togetherApiKey.trim();
|
||||
}
|
||||
if (form.fireworksApiKey.trim()) {
|
||||
payload.fireworks_api_key = form.fireworksApiKey.trim();
|
||||
}
|
||||
if (form.geminiApiKey.trim()) {
|
||||
payload.gemini_api_key = form.geminiApiKey.trim();
|
||||
}
|
||||
|
|
@ -1027,6 +1123,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
current?.openai_configured,
|
||||
current?.openrouter_configured,
|
||||
current?.deepseek_configured,
|
||||
current?.zai_configured,
|
||||
current?.groq_configured,
|
||||
current?.mistral_configured,
|
||||
current?.cerebras_configured,
|
||||
current?.together_configured,
|
||||
current?.fireworks_configured,
|
||||
current?.gemini_configured,
|
||||
current?.ollama_configured,
|
||||
].filter(Boolean).length;
|
||||
|
|
@ -1055,6 +1157,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
if (provider === 'openai') clearPayload.clear_openai_key = true;
|
||||
if (provider === 'openrouter') clearPayload.clear_openrouter_key = true;
|
||||
if (provider === 'deepseek') clearPayload.clear_deepseek_key = true;
|
||||
if (provider === 'zai') clearPayload.clear_zai_key = true;
|
||||
if (provider === 'groq') clearPayload.clear_groq_key = true;
|
||||
if (provider === 'mistral') clearPayload.clear_mistral_key = true;
|
||||
if (provider === 'cerebras') clearPayload.clear_cerebras_key = true;
|
||||
if (provider === 'together') clearPayload.clear_together_key = true;
|
||||
if (provider === 'fireworks') clearPayload.clear_fireworks_key = true;
|
||||
if (provider === 'gemini') clearPayload.clear_gemini_key = true;
|
||||
if (provider === 'ollama') clearPayload.clear_ollama_url = true;
|
||||
|
||||
|
|
@ -1068,6 +1176,12 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
if (provider === 'openai') setForm('openaiApiKey', '');
|
||||
if (provider === 'openrouter') setForm('openrouterApiKey', '');
|
||||
if (provider === 'deepseek') setForm('deepseekApiKey', '');
|
||||
if (provider === 'zai') setForm('zaiApiKey', '');
|
||||
if (provider === 'groq') setForm('groqApiKey', '');
|
||||
if (provider === 'mistral') setForm('mistralApiKey', '');
|
||||
if (provider === 'cerebras') setForm('cerebrasApiKey', '');
|
||||
if (provider === 'together') setForm('togetherApiKey', '');
|
||||
if (provider === 'fireworks') setForm('fireworksApiKey', '');
|
||||
if (provider === 'gemini') setForm('geminiApiKey', '');
|
||||
if (provider === 'ollama') setForm('ollamaBaseUrl', '');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,41 @@
|
|||
// AI feature types
|
||||
|
||||
export type AIProvider = 'anthropic' | 'openai' | 'openrouter' | 'ollama' | 'deepseek' | 'gemini';
|
||||
export type AIProvider =
|
||||
| 'anthropic'
|
||||
| 'openai'
|
||||
| 'openrouter'
|
||||
| 'ollama'
|
||||
| 'deepseek'
|
||||
| 'gemini'
|
||||
| 'zai'
|
||||
| 'groq'
|
||||
| 'mistral'
|
||||
| 'cerebras'
|
||||
| 'together'
|
||||
| 'fireworks';
|
||||
export type AuthMethod = 'api_key' | 'oauth';
|
||||
export type PatrolReadinessStatus = 'ready' | 'warning' | 'not_ready';
|
||||
|
||||
export interface AIProviderDefinition {
|
||||
id: AIProvider | string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
protocol: 'anthropic' | 'openai_compatible' | 'gemini' | 'ollama' | 'retired' | string;
|
||||
default_model?: string;
|
||||
default_base_url?: string;
|
||||
api_key_field?: string;
|
||||
configured_field?: string;
|
||||
clear_key_field?: string;
|
||||
base_url_field?: string;
|
||||
requires_api_key: boolean;
|
||||
user_configurable: boolean;
|
||||
gateway: boolean;
|
||||
configured: boolean;
|
||||
models_dev_provider_id?: string;
|
||||
env_vars: string[];
|
||||
docs_url?: string;
|
||||
}
|
||||
|
||||
export interface PatrolReadinessCheck {
|
||||
id: string;
|
||||
status: PatrolReadinessStatus;
|
||||
|
|
@ -62,11 +94,18 @@ export interface AISettings {
|
|||
openrouter_configured: boolean; // true if OpenRouter API key is set
|
||||
deepseek_configured: boolean; // true if DeepSeek API key is set
|
||||
gemini_configured: boolean; // true if Gemini API key is set
|
||||
zai_configured?: boolean; // true if Z.ai (Zhipu) API key is set
|
||||
groq_configured?: boolean; // true if Groq API key is set
|
||||
mistral_configured?: boolean; // true if Mistral API key is set
|
||||
cerebras_configured?: boolean; // true if Cerebras API key is set
|
||||
together_configured?: boolean; // true if Together AI API key is set
|
||||
fireworks_configured?: boolean; // true if Fireworks AI API key is set
|
||||
ollama_configured: boolean; // true (always available for attempt)
|
||||
ollama_base_url: string; // Ollama server URL
|
||||
ollama_keep_alive: string; // Ollama keep_alive value; empty uses the server default
|
||||
openai_base_url?: string; // Custom OpenAI base URL
|
||||
configured_providers: AIProvider[]; // List of providers with credentials
|
||||
providers?: AIProviderDefinition[]; // Server-authored provider registry metadata
|
||||
|
||||
// Cost controls (30-day budget, pro-rated in UI)
|
||||
cost_budget_usd_30d?: number;
|
||||
|
|
@ -131,6 +170,12 @@ export interface AISettingsUpdateRequest {
|
|||
openrouter_api_key?: string; // Set OpenRouter API key
|
||||
deepseek_api_key?: string; // Set DeepSeek API key
|
||||
gemini_api_key?: string; // Set Gemini API key
|
||||
zai_api_key?: string; // Set Z.ai (Zhipu) API key
|
||||
groq_api_key?: string; // Set Groq API key
|
||||
mistral_api_key?: string; // Set Mistral API key
|
||||
cerebras_api_key?: string; // Set Cerebras API key
|
||||
together_api_key?: string; // Set Together AI API key
|
||||
fireworks_api_key?: string; // Set Fireworks AI API key
|
||||
ollama_base_url?: string; // Set Ollama server URL
|
||||
ollama_keep_alive?: string; // Set Ollama keep_alive; empty uses the server default
|
||||
openai_base_url?: string; // Set custom OpenAI base URL
|
||||
|
|
@ -140,6 +185,12 @@ export interface AISettingsUpdateRequest {
|
|||
clear_openrouter_key?: boolean; // Clear OpenRouter API key
|
||||
clear_deepseek_key?: boolean; // Clear DeepSeek API key
|
||||
clear_gemini_key?: boolean; // Clear Gemini API key
|
||||
clear_zai_key?: boolean; // Clear Z.ai API key
|
||||
clear_groq_key?: boolean; // Clear Groq API key
|
||||
clear_mistral_key?: boolean; // Clear Mistral API key
|
||||
clear_cerebras_key?: boolean; // Clear Cerebras API key
|
||||
clear_together_key?: boolean; // Clear Together AI API key
|
||||
clear_fireworks_key?: boolean; // Clear Fireworks AI API key
|
||||
clear_ollama_url?: boolean; // Clear Ollama URL
|
||||
|
||||
// Cost controls
|
||||
|
|
@ -186,6 +237,12 @@ export const PROVIDER_DESCRIPTIONS: Record<AIProvider, string> = {
|
|||
ollama: 'Local models via Ollama',
|
||||
deepseek: 'DeepSeek V4 models',
|
||||
gemini: 'Gemini models from Google',
|
||||
zai: 'GLM models from Z.ai',
|
||||
groq: 'Hosted models from Groq',
|
||||
mistral: 'Mistral models',
|
||||
cerebras: 'Cerebras Inference models',
|
||||
together: 'Together AI hosted models',
|
||||
fireworks: 'Fireworks AI hosted models',
|
||||
};
|
||||
|
||||
// Conversation history for multi-turn chats
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ export const AI_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||
openrouter: 'OpenRouter',
|
||||
deepseek: 'DeepSeek',
|
||||
gemini: 'Google Gemini',
|
||||
zai: 'Z.ai',
|
||||
groq: 'Groq',
|
||||
mistral: 'Mistral',
|
||||
cerebras: 'Cerebras',
|
||||
together: 'Together AI',
|
||||
fireworks: 'Fireworks AI',
|
||||
ollama: 'Ollama',
|
||||
pulse: 'Pulse',
|
||||
};
|
||||
|
|
@ -45,6 +51,18 @@ export function getProviderFromModelId(modelId: string): AIProvider | string {
|
|||
if (modelId.includes('gemini')) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (modelId.includes('glm')) {
|
||||
return 'zai';
|
||||
}
|
||||
if (modelId.includes('groq')) {
|
||||
return 'groq';
|
||||
}
|
||||
if (modelId.includes('mistral')) {
|
||||
return 'mistral';
|
||||
}
|
||||
if (modelId.includes('cerebras')) {
|
||||
return 'cerebras';
|
||||
}
|
||||
return 'ollama';
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +112,7 @@ const MODEL_TOKEN_DISPLAY_NAMES: Record<string, string> = {
|
|||
deepseek: 'DeepSeek',
|
||||
flash: 'Flash',
|
||||
gemini: 'Gemini',
|
||||
glm: 'GLM',
|
||||
gpt: 'GPT',
|
||||
haiku: 'Haiku',
|
||||
instruct: 'Instruct',
|
||||
|
|
@ -169,15 +188,24 @@ const titleizeModelToken = (token: string): string => {
|
|||
if (/^[rv][0-9]/.test(normalized)) {
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
if (/^[0-9]+b$/.test(normalized)) {
|
||||
return normalized.toUpperCase();
|
||||
}
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
};
|
||||
|
||||
const titleizeModelRouteId = (modelId: string): string =>
|
||||
modelId
|
||||
.split(/[-_\s]+/)
|
||||
.split('/')
|
||||
.map((segment) =>
|
||||
segment
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(titleizeModelToken)
|
||||
.join(' '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.map(titleizeModelToken)
|
||||
.join(' ');
|
||||
.join('/');
|
||||
|
||||
const gatewayFallbackLabel = (model: AIModelRouteLabelInput): string => {
|
||||
if (explicitModelNameForLabel(model)) return '';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
342
internal/config/ai_providers.go
Normal file
342
internal/config/ai_providers.go
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// AIProviderProtocol identifies the transport/runtime shape a provider uses.
|
||||
type AIProviderProtocol string
|
||||
|
||||
const (
|
||||
AIProviderProtocolAnthropic AIProviderProtocol = "anthropic"
|
||||
AIProviderProtocolOpenAICompatible AIProviderProtocol = "openai_compatible"
|
||||
AIProviderProtocolGemini AIProviderProtocol = "gemini"
|
||||
AIProviderProtocolOllama AIProviderProtocol = "ollama"
|
||||
AIProviderProtocolRetired AIProviderProtocol = "retired"
|
||||
)
|
||||
|
||||
// AIProviderModelDefinition is the config-layer representation of catalog
|
||||
// models that Pulse should present even when a provider's /models endpoint is
|
||||
// absent, incomplete, or temporarily unavailable.
|
||||
type AIProviderModelDefinition struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Notable bool
|
||||
}
|
||||
|
||||
// AIProviderDefinition is the canonical internal registry record for provider
|
||||
// metadata and runtime capability selection.
|
||||
type AIProviderDefinition struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
Description string
|
||||
Protocol AIProviderProtocol
|
||||
DefaultModel string
|
||||
DefaultBaseURL string
|
||||
APIKeyField string
|
||||
ConfiguredField string
|
||||
ClearKeyField string
|
||||
BaseURLField string
|
||||
RequiresAPIKey bool
|
||||
UserConfigurable bool
|
||||
Gateway bool
|
||||
ModelsDevProviderID string
|
||||
EnvVars []string
|
||||
DocsURL string
|
||||
FallbackModels []AIProviderModelDefinition
|
||||
}
|
||||
|
||||
func aiProviderDefinitions() []AIProviderDefinition {
|
||||
return []AIProviderDefinition{
|
||||
{
|
||||
ID: AIProviderAnthropic,
|
||||
DisplayName: "Anthropic",
|
||||
Description: "Claude models from Anthropic",
|
||||
Protocol: AIProviderProtocolAnthropic,
|
||||
DefaultModel: "claude-3-5-sonnet-latest",
|
||||
APIKeyField: "anthropic_api_key",
|
||||
ConfiguredField: "anthropic_configured",
|
||||
ClearKeyField: "clear_anthropic_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
EnvVars: []string{"ANTHROPIC_API_KEY"},
|
||||
DocsURL: "https://docs.anthropic.com/en/api/getting-started",
|
||||
},
|
||||
{
|
||||
ID: AIProviderOpenAI,
|
||||
DisplayName: "OpenAI",
|
||||
Description: "GPT and reasoning models from OpenAI, or a custom OpenAI-compatible endpoint",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: "gpt-4o",
|
||||
APIKeyField: "openai_api_key",
|
||||
ConfiguredField: "openai_configured",
|
||||
ClearKeyField: "clear_openai_key",
|
||||
BaseURLField: "openai_base_url",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
EnvVars: []string{"OPENAI_API_KEY"},
|
||||
DocsURL: "https://platform.openai.com/docs/api-reference/chat",
|
||||
},
|
||||
{
|
||||
ID: AIProviderOpenRouter,
|
||||
DisplayName: "OpenRouter",
|
||||
Description: "Gateway for OpenAI-compatible hosted models",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: "openai/gpt-4o-mini",
|
||||
DefaultBaseURL: DefaultOpenRouterBaseURL,
|
||||
APIKeyField: "openrouter_api_key",
|
||||
ConfiguredField: "openrouter_configured",
|
||||
ClearKeyField: "clear_openrouter_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
Gateway: true,
|
||||
ModelsDevProviderID: "openrouter",
|
||||
EnvVars: []string{"OPENROUTER_API_KEY"},
|
||||
DocsURL: "https://openrouter.ai/docs",
|
||||
},
|
||||
{
|
||||
ID: AIProviderDeepSeek,
|
||||
DisplayName: "DeepSeek",
|
||||
Description: "DeepSeek direct API models",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: DeepSeekModelV4Flash,
|
||||
DefaultBaseURL: DefaultDeepSeekBaseURL,
|
||||
APIKeyField: "deepseek_api_key",
|
||||
ConfiguredField: "deepseek_configured",
|
||||
ClearKeyField: "clear_deepseek_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "deepseek",
|
||||
EnvVars: []string{"DEEPSEEK_API_KEY"},
|
||||
DocsURL: "https://api-docs.deepseek.com/",
|
||||
FallbackModels: []AIProviderModelDefinition{
|
||||
{
|
||||
ID: DeepSeekModelV4Flash,
|
||||
Name: "DeepSeek V4 Flash",
|
||||
Description: "DeepSeek: current V4 Flash direct API model",
|
||||
Notable: true,
|
||||
},
|
||||
{
|
||||
ID: DeepSeekModelV4Pro,
|
||||
Name: "DeepSeek V4 Pro",
|
||||
Description: "DeepSeek: current V4 Pro direct API model",
|
||||
Notable: true,
|
||||
},
|
||||
{
|
||||
ID: DeepSeekModelLegacyChat,
|
||||
Name: "DeepSeek Chat (legacy alias)",
|
||||
Description: "DeepSeek: legacy alias currently routing to V4 Flash",
|
||||
},
|
||||
{
|
||||
ID: DeepSeekModelLegacyReasoner,
|
||||
Name: "DeepSeek Reasoner (legacy alias)",
|
||||
Description: "DeepSeek: legacy alias currently routing to V4 Flash",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: AIProviderGemini,
|
||||
DisplayName: "Google Gemini",
|
||||
Description: "Gemini models from Google",
|
||||
Protocol: AIProviderProtocolGemini,
|
||||
DefaultModel: "gemini-1.5-pro",
|
||||
DefaultBaseURL: DefaultGeminiBaseURL,
|
||||
APIKeyField: "gemini_api_key",
|
||||
ConfiguredField: "gemini_configured",
|
||||
ClearKeyField: "clear_gemini_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "google",
|
||||
EnvVars: []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"},
|
||||
DocsURL: "https://ai.google.dev/gemini-api/docs",
|
||||
},
|
||||
{
|
||||
ID: AIProviderZai,
|
||||
DisplayName: "Z.ai",
|
||||
Description: "GLM models from Z.ai using the OpenAI-compatible API",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: "glm-5.2",
|
||||
DefaultBaseURL: DefaultZaiBaseURL,
|
||||
APIKeyField: "zai_api_key",
|
||||
ConfiguredField: "zai_configured",
|
||||
ClearKeyField: "clear_zai_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "zai",
|
||||
EnvVars: []string{"ZHIPU_API_KEY", "ZAI_API_KEY"},
|
||||
DocsURL: "https://docs.z.ai/guides/develop/openai/python",
|
||||
FallbackModels: []AIProviderModelDefinition{
|
||||
{
|
||||
ID: "glm-5.2",
|
||||
Name: "GLM 5.2",
|
||||
Description: "Z.ai: GLM 5.2 direct API model",
|
||||
Notable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: AIProviderGroq,
|
||||
DisplayName: "Groq",
|
||||
Description: "Groq hosted models using the OpenAI-compatible API",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: "llama-3.3-70b-versatile",
|
||||
DefaultBaseURL: DefaultGroqBaseURL,
|
||||
APIKeyField: "groq_api_key",
|
||||
ConfiguredField: "groq_configured",
|
||||
ClearKeyField: "clear_groq_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "groq",
|
||||
EnvVars: []string{"GROQ_API_KEY"},
|
||||
DocsURL: "https://console.groq.com/docs/openai",
|
||||
},
|
||||
{
|
||||
ID: AIProviderMistral,
|
||||
DisplayName: "Mistral",
|
||||
Description: "Mistral models using the OpenAI-compatible chat API",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: "mistral-large-latest",
|
||||
DefaultBaseURL: DefaultMistralBaseURL,
|
||||
APIKeyField: "mistral_api_key",
|
||||
ConfiguredField: "mistral_configured",
|
||||
ClearKeyField: "clear_mistral_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "mistral",
|
||||
EnvVars: []string{"MISTRAL_API_KEY"},
|
||||
DocsURL: "https://docs.mistral.ai/resources/migration-guides",
|
||||
},
|
||||
{
|
||||
ID: AIProviderCerebras,
|
||||
DisplayName: "Cerebras",
|
||||
Description: "Cerebras Inference models using the OpenAI-compatible API",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: "llama-4-scout-17b-16e-instruct",
|
||||
DefaultBaseURL: DefaultCerebrasBaseURL,
|
||||
APIKeyField: "cerebras_api_key",
|
||||
ConfiguredField: "cerebras_configured",
|
||||
ClearKeyField: "clear_cerebras_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "cerebras",
|
||||
EnvVars: []string{"CEREBRAS_API_KEY"},
|
||||
DocsURL: "https://inference-docs.cerebras.ai/resources/openai",
|
||||
},
|
||||
{
|
||||
ID: AIProviderTogether,
|
||||
DisplayName: "Together AI",
|
||||
Description: "Together AI models using the OpenAI-compatible API",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
DefaultBaseURL: DefaultTogetherBaseURL,
|
||||
APIKeyField: "together_api_key",
|
||||
ConfiguredField: "together_configured",
|
||||
ClearKeyField: "clear_together_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "togetherai",
|
||||
EnvVars: []string{"TOGETHER_API_KEY"},
|
||||
DocsURL: "https://docs.together.ai/docs/inference/openai-compatibility",
|
||||
},
|
||||
{
|
||||
ID: AIProviderFireworks,
|
||||
DisplayName: "Fireworks AI",
|
||||
Description: "Fireworks models using the OpenAI-compatible API",
|
||||
Protocol: AIProviderProtocolOpenAICompatible,
|
||||
DefaultModel: "accounts/fireworks/models/llama-v3p1-70b-instruct",
|
||||
DefaultBaseURL: DefaultFireworksBaseURL,
|
||||
APIKeyField: "fireworks_api_key",
|
||||
ConfiguredField: "fireworks_configured",
|
||||
ClearKeyField: "clear_fireworks_key",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "fireworks-ai",
|
||||
EnvVars: []string{"FIREWORKS_API_KEY"},
|
||||
DocsURL: "https://docs.fireworks.ai/tools-sdks/openai-compatibility",
|
||||
},
|
||||
{
|
||||
ID: AIProviderOllama,
|
||||
DisplayName: "Ollama",
|
||||
Description: "Local models served by Ollama",
|
||||
Protocol: AIProviderProtocolOllama,
|
||||
DefaultModel: "llama3.2",
|
||||
DefaultBaseURL: DefaultOllamaBaseURL,
|
||||
ConfiguredField: "ollama_configured",
|
||||
ClearKeyField: "clear_ollama_url",
|
||||
BaseURLField: "ollama_base_url",
|
||||
UserConfigurable: true,
|
||||
DocsURL: "https://ollama.com",
|
||||
},
|
||||
{
|
||||
ID: AIProviderQuickstart,
|
||||
DisplayName: "Pulse hosted quickstart",
|
||||
Description: "Retired pre-GA hosted proxy marker retained for migration cleanup",
|
||||
Protocol: AIProviderProtocolRetired,
|
||||
DefaultModel: "",
|
||||
ConfiguredField: "quickstart_configured",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AIProviderDefinitions returns the ordered provider registry. The slice and
|
||||
// nested slices are copies so callers cannot mutate the canonical records.
|
||||
func AIProviderDefinitions() []AIProviderDefinition {
|
||||
defs := aiProviderDefinitions()
|
||||
for i := range defs {
|
||||
defs[i].EnvVars = append([]string(nil), defs[i].EnvVars...)
|
||||
defs[i].FallbackModels = append([]AIProviderModelDefinition(nil), defs[i].FallbackModels...)
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// AIConfigurableProviderDefinitions returns user-configurable providers in UI
|
||||
// and model-list order.
|
||||
func AIConfigurableProviderDefinitions() []AIProviderDefinition {
|
||||
defs := AIProviderDefinitions()
|
||||
out := make([]AIProviderDefinition, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
if def.UserConfigurable {
|
||||
out = append(out, def)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func LookupAIProviderDefinition(provider string) (AIProviderDefinition, bool) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider == "" {
|
||||
return AIProviderDefinition{}, false
|
||||
}
|
||||
for _, def := range aiProviderDefinitions() {
|
||||
if def.ID == provider {
|
||||
def.EnvVars = append([]string(nil), def.EnvVars...)
|
||||
def.FallbackModels = append([]AIProviderModelDefinition(nil), def.FallbackModels...)
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
return AIProviderDefinition{}, false
|
||||
}
|
||||
|
||||
func IsKnownAIProvider(provider string) bool {
|
||||
_, ok := LookupAIProviderDefinition(provider)
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsOpenAICompatibleProvider(provider string) bool {
|
||||
def, ok := LookupAIProviderDefinition(provider)
|
||||
return ok && def.Protocol == AIProviderProtocolOpenAICompatible
|
||||
}
|
||||
|
||||
func AIProviderFallbackModels(provider string) []AIProviderModelDefinition {
|
||||
def, ok := LookupAIProviderDefinition(provider)
|
||||
if !ok || len(def.FallbackModels) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]AIProviderModelDefinition(nil), def.FallbackModels...)
|
||||
}
|
||||
|
||||
func AIProviderDisplayName(provider string) string {
|
||||
if def, ok := LookupAIProviderDefinition(provider); ok && def.DisplayName != "" {
|
||||
return def.DisplayName
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue