mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Make Z.ai provider base URL user-overridable
Z.ai keys on the coding subscription get 429 'Insufficient balance' on the standard /api/paas/v4 endpoint. Add a per-provider BaseURLField to the Z.ai registry entry, a ZaiBaseURL override on AIConfig (returned by GetBaseURLForProvider when set, else the standard default), the handler request/response/apply plumbing, and a 'Custom Base URL' field on the Z.ai provider card so operators can point at /api/coding/paas/v4. Mirrors the existing OpenAI custom-base-URL override; the standard endpoint remains the default for pay-as-you-go users. Adds config/handler/frontend proofs and updates the ai-runtime, api-contracts, frontend-primitives, agent-lifecycle, and storage-recovery contracts.
This commit is contained in:
parent
9346d2f99d
commit
1f6fe16e49
15 changed files with 61 additions and 10 deletions
|
|
@ -113,7 +113,10 @@ that case means governed actions have a node-agent path; it must not be
|
|||
presented as proof that a guest-local agent is installed.
|
||||
Agent-facing operations-loop status wiring in `internal/api/router.go` and
|
||||
`internal/api/agent_resource_context.go` is lifecycle-adjacent only because it
|
||||
shares agent route infrastructure. Workflow starter counts on that endpoint,
|
||||
shares agent route infrastructure. Other handlers in `internal/api/` such as
|
||||
the AI settings handler (`ai_handlers.go`) carry AI provider configuration
|
||||
(for example per-provider base URL overrides) that is ai-runtime config-surface
|
||||
and is not agent enrollment, liveness, or lifecycle state. Workflow starter counts on that endpoint,
|
||||
contextual Assistant/external-agent collaboration counts inside the Assistant
|
||||
step, the content-free Patrol control starter split, and Patrol control
|
||||
completed-loop, resolved-loop, or `patrolControlValueState` proof mirrored to
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ 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.
|
||||
Provider definitions that declare a BaseURLField (today OpenAI, Ollama, and
|
||||
Z.ai) expose a user-overridable endpoint via the AI settings payload; the
|
||||
registry default base URL applies when no override is stored, so one provider
|
||||
can serve both standard and alternate (e.g. Z.ai coding) endpoint tiers.
|
||||
|
||||
## Canonical Files
|
||||
|
||||
|
|
|
|||
|
|
@ -2401,7 +2401,7 @@ payload shape change when the portal presents compact client rows.
|
|||
20. Backend config/settings handlers pointing operator guidance at GitHub `main` docs when the running build already ships that guidance locally under `/docs/`
|
||||
21. Telemetry preview or reset endpoints drifting from the exact server-owned telemetry runtime contract instead of reusing the same source-of-truth snapshot and install-ID state the background sender uses
|
||||
22. Shared SSO test or metadata-preview handlers open-coding outbound metadata/discovery URLs, allowing userinfo-bearing HTTP(S) inputs, or rebuilding `/.well-known/openid-configuration` with origin-root string concatenation instead of the shared validated URL helpers before any outbound request
|
||||
23. AI settings handlers echoing raw provider secrets or testing the wrong provider model: `/api/settings/ai` may expose masked provider-auth presence such as `ollama_password_set`, but backend payloads must never echo stored secrets back to clients, and provider-specific test routes must stay bound to the selected provider's own configured model instead of whichever other provider currently owns the default `model` field
|
||||
23. AI settings handlers echoing raw provider secrets or testing the wrong provider model: `/api/settings/ai` may expose masked provider-auth presence such as `ollama_password_set`, but backend payloads must never echo stored secrets back to clients, and provider-specific test routes must stay bound to the selected provider's own configured model instead of whichever other provider currently owns the default `model` field; per-provider base URL overrides (`openai_base_url`, `zai_base_url`, and Ollama's `ollama_base_url`) round-trip through the same settings payload and take precedence over the registry default, so a provider can be retargeted (e.g. Z.ai standard versus coding endpoint) without code changes
|
||||
24. `/api/diagnostics` exposing maintainer/admin analytics such as commercial
|
||||
funnel, sales funnel, pricing/checkout conversion, or infrastructure
|
||||
onboarding telemetry. Customer diagnostics may expose runtime health,
|
||||
|
|
|
|||
|
|
@ -2194,7 +2194,8 @@ the shared primitive rather than a local bordered button group.
|
|||
AI settings provider fields are a governed frontend primitive, not a
|
||||
provider-local form fork. The shared provider configuration section must render
|
||||
provider-specific controls from `aiSettingsModel.ts` `extraFields`, including
|
||||
Ollama `keep_alive`, so Assistant and Patrol keep one settings shape across
|
||||
Ollama `keep_alive` and the Z.ai custom base URL override, so Assistant and
|
||||
Patrol keep one settings shape across
|
||||
labeling, help affordances, helper copy, and persistence binding.
|
||||
The shared AI model picker owns model route search and presentation for
|
||||
Assistant surfaces. External open requests may seed an initial search query,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,10 @@ remain governed by the setup-script and source-specific backup API boundaries
|
|||
below.
|
||||
Operations-loop status wiring in `internal/api/agent_resource_context.go` is
|
||||
storage/recovery-adjacent only through the shared action-audit and verification
|
||||
projection. Starter counts, contextual Assistant/external-agent collaboration
|
||||
projection. Sibling handlers in `internal/api/` such as the AI settings handler
|
||||
(`ai_handlers.go`) carry AI provider configuration (for example per-provider
|
||||
base URL overrides) that is ai-runtime config-surface, not storage or recovery
|
||||
state. Starter counts, contextual Assistant/external-agent collaboration
|
||||
counts, Patrol control completed-loop or resolved-loop outcome evidence,
|
||||
`patrolControlValueState`, legacy `patrolAutonomy*` compatibility aliases, and
|
||||
the operator-readable `progressLabel` on that
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ describe('AIAPI', () => {
|
|||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/settings/ai');
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({ configured: true } as any);
|
||||
await AIAPI.updateSettings({ enabled: true });
|
||||
await AIAPI.updateSettings({ enabled: true, zai_base_url: 'https://api.z.ai/api/coding/paas/v4' });
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/settings/ai/update', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
body: JSON.stringify({ enabled: true, zai_base_url: 'https://api.z.ai/api/coding/paas/v4' }),
|
||||
});
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({ models: [] } as any);
|
||||
|
|
|
|||
|
|
@ -673,6 +673,7 @@ describe('settings architecture guardrails', () => {
|
|||
expect(aiSettingsModelSource).toContain('extraFields: [');
|
||||
expect(aiSettingsModelSource).toContain("inputField: 'ollamaKeepAlive'");
|
||||
expect(aiSettingsModelSource).toContain("helpContentId: 'ai.ollama.keepAlive'");
|
||||
expect(aiSettingsModelSource).toContain("inputField: 'zaiBaseUrl'");
|
||||
expect(aiProviderConfigurationSectionSource).toContain('<For each={config.extraFields || []}>');
|
||||
expect(aiProviderConfigurationSectionSource).toContain(
|
||||
'aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField.label}`}',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type AIProviderCredentialsFormState = {
|
|||
ollamaBaseUrl: string;
|
||||
ollamaKeepAlive: string;
|
||||
openaiBaseUrl: string;
|
||||
zaiBaseUrl: string;
|
||||
};
|
||||
|
||||
export type AIProviderConfig = {
|
||||
|
|
@ -162,6 +163,15 @@ export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
|
|||
actionLinkLabel: 'Open docs →',
|
||||
actionLinkHref: 'https://docs.z.ai/guides/develop/openai/python',
|
||||
helperText: 'Uses https://api.z.ai/api/paas/v4 automatically.',
|
||||
extraFields: [
|
||||
{
|
||||
label: 'Custom Base URL',
|
||||
inputField: 'zaiBaseUrl',
|
||||
placeholder: 'https://api.z.ai/api/coding/paas/v4 (coding plan)',
|
||||
type: 'url',
|
||||
helperText: 'Override for a z.ai coding subscription.',
|
||||
},
|
||||
],
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ 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'],
|
||||
zai: ['zai_api_key', 'zai_base_url'],
|
||||
groq: ['groq_api_key'],
|
||||
mistral: ['mistral_api_key'],
|
||||
cerebras: ['cerebras_api_key'],
|
||||
|
|
@ -322,6 +322,7 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
ollamaKeepAlive: '30s',
|
||||
openaiBaseUrl: '',
|
||||
zaiBaseUrl: '',
|
||||
costBudgetUSD30d: '',
|
||||
requestTimeoutSeconds: 300,
|
||||
controlLevel: 'read_only' as AIControlLevel,
|
||||
|
|
@ -413,6 +414,7 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
ollamaKeepAlive: '30s',
|
||||
openaiBaseUrl: '',
|
||||
zaiBaseUrl: '',
|
||||
costBudgetUSD30d: '',
|
||||
requestTimeoutSeconds: 300,
|
||||
controlLevel: 'read_only',
|
||||
|
|
@ -455,6 +457,7 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
|
||||
ollamaKeepAlive: data.ollama_keep_alive ?? '30s',
|
||||
openaiBaseUrl: data.openai_base_url || '',
|
||||
zaiBaseUrl: data.zai_base_url || '',
|
||||
costBudgetUSD30d:
|
||||
typeof data.cost_budget_usd_30d === 'number' && data.cost_budget_usd_30d > 0
|
||||
? String(data.cost_budget_usd_30d)
|
||||
|
|
@ -980,6 +983,9 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
|||
if (form.openaiBaseUrl !== (settings()?.openai_base_url || '')) {
|
||||
payload.openai_base_url = form.openaiBaseUrl.trim();
|
||||
}
|
||||
if (form.zaiBaseUrl !== (settings()?.zai_base_url || '')) {
|
||||
payload.zai_base_url = form.zaiBaseUrl.trim();
|
||||
}
|
||||
|
||||
const rawBudget = form.costBudgetUSD30d.trim();
|
||||
const parsedBudget = rawBudget === '' ? 0 : Number(rawBudget);
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export interface AISettings {
|
|||
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
|
||||
zai_base_url?: string; // Custom Z.ai base URL (e.g. coding endpoint)
|
||||
configured_providers: AIProvider[]; // List of providers with credentials
|
||||
providers?: AIProviderDefinition[]; // Server-authored provider registry metadata
|
||||
|
||||
|
|
@ -179,6 +180,7 @@ export interface AISettingsUpdateRequest {
|
|||
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
|
||||
zai_base_url?: string; // Set custom Z.ai base URL (e.g. coding endpoint)
|
||||
// Clear flags for removing credentials
|
||||
clear_anthropic_key?: boolean; // Clear Anthropic API key
|
||||
clear_openai_key?: boolean; // Clear OpenAI API key
|
||||
|
|
|
|||
|
|
@ -1685,6 +1685,7 @@ func shouldRestartAIChat(req AISettingsUpdateRequest) bool {
|
|||
req.OllamaPassword != nil ||
|
||||
req.OllamaKeepAlive != nil ||
|
||||
req.OpenAIBaseURL != nil ||
|
||||
req.ZaiBaseURL != nil ||
|
||||
req.ClearAnthropicKey != nil ||
|
||||
req.ClearOpenAIKey != nil ||
|
||||
req.ClearOpenRouterKey != nil ||
|
||||
|
|
@ -1761,6 +1762,7 @@ func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool {
|
|||
req.OllamaPassword != nil ||
|
||||
req.OllamaKeepAlive != nil ||
|
||||
req.OpenAIBaseURL != nil ||
|
||||
req.ZaiBaseURL != nil ||
|
||||
req.ClearAnthropicKey != nil ||
|
||||
req.ClearOpenAIKey != nil ||
|
||||
req.ClearOpenRouterKey != nil ||
|
||||
|
|
@ -2320,6 +2322,7 @@ type AISettingsResponse struct {
|
|||
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
|
||||
ZaiBaseURL string `json:"zai_base_url,omitempty"` // Custom Z.ai base URL (e.g. coding endpoint)
|
||||
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
|
||||
Providers []AIProviderDefinitionResponse `json:"providers"` // Canonical provider registry metadata
|
||||
// Cost controls
|
||||
|
|
@ -2469,6 +2472,7 @@ type AISettingsUpdateRequest struct {
|
|||
OllamaPassword *string `json:"ollama_password,omitempty"` // Set Ollama Basic Auth password
|
||||
OllamaKeepAlive *string `json:"ollama_keep_alive,omitempty"` // Set Ollama keep_alive; empty uses server default
|
||||
OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL
|
||||
ZaiBaseURL *string `json:"zai_base_url,omitempty"` // Set custom Z.ai base URL (e.g. coding endpoint)
|
||||
// Clear flags for removing credentials
|
||||
ClearAnthropicKey *bool `json:"clear_anthropic_key,omitempty"` // Clear Anthropic API key
|
||||
ClearOpenAIKey *bool `json:"clear_openai_key,omitempty"` // Clear OpenAI API key
|
||||
|
|
@ -2620,6 +2624,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
|
|||
OllamaPasswordSet: settings.OllamaPassword != "",
|
||||
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
|
||||
OpenAIBaseURL: settings.OpenAIBaseURL,
|
||||
ZaiBaseURL: settings.ZaiBaseURL,
|
||||
ConfiguredProviders: settings.GetConfiguredProviders(),
|
||||
Providers: aiProviderDefinitionResponses(settings),
|
||||
CostBudgetUSD30d: settings.CostBudgetUSD30d,
|
||||
|
|
@ -2810,6 +2815,9 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
|||
if req.OpenAIBaseURL != nil {
|
||||
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
|
||||
}
|
||||
if req.ZaiBaseURL != nil {
|
||||
settings.ZaiBaseURL = strings.TrimSpace(*req.ZaiBaseURL)
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
// Only allow enabling if at least one BYOK/local provider is configured.
|
||||
|
|
@ -3110,6 +3118,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
|||
OllamaPasswordSet: settings.OllamaPassword != "",
|
||||
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
|
||||
OpenAIBaseURL: settings.OpenAIBaseURL,
|
||||
ZaiBaseURL: settings.ZaiBaseURL,
|
||||
ConfiguredProviders: settings.GetConfiguredProviders(),
|
||||
Providers: aiProviderDefinitionResponses(settings),
|
||||
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
|
||||
|
|
|
|||
|
|
@ -639,6 +639,7 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
|
|||
OllamaUsername: ptr("unai"),
|
||||
OllamaPassword: ptr("secret"),
|
||||
OllamaKeepAlive: ptr("24h"),
|
||||
ZaiBaseURL: ptr("https://api.z.ai/api/coding/paas/v4"),
|
||||
})
|
||||
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -658,6 +659,9 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
|
|||
if resp.OllamaBaseURL != "http://localhost:11434" {
|
||||
t.Fatalf("unexpected ollama base url: %+v", resp)
|
||||
}
|
||||
if resp.ZaiBaseURL != "https://api.z.ai/api/coding/paas/v4" {
|
||||
t.Fatalf("unexpected zai base url: %+v", resp)
|
||||
}
|
||||
if resp.OllamaUsername != "unai" || !resp.OllamaPasswordSet {
|
||||
t.Fatalf("expected ollama auth state in response, got %+v", resp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ type AIConfig struct {
|
|||
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
|
||||
ZaiBaseURL string `json:"zai_base_url,omitempty"` // Custom Z.ai OpenAI-compatible base URL (e.g. coding endpoint)
|
||||
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
|
||||
|
|
@ -466,12 +467,14 @@ func (c *AIConfig) GetBaseURLForProvider(provider string) string {
|
|||
if c != nil && c.OllamaBaseURL != "" {
|
||||
return c.OllamaBaseURL
|
||||
}
|
||||
return DefaultOllamaBaseURL
|
||||
case AIProviderOpenAI:
|
||||
if c != nil && c.OpenAIBaseURL != "" {
|
||||
return c.OpenAIBaseURL
|
||||
}
|
||||
return "" // Uses default OpenAI URL
|
||||
case AIProviderZai:
|
||||
if c != nil && c.ZaiBaseURL != "" {
|
||||
return c.ZaiBaseURL
|
||||
}
|
||||
}
|
||||
if def, ok := LookupAIProviderDefinition(provider); ok {
|
||||
return def.DefaultBaseURL
|
||||
|
|
|
|||
|
|
@ -542,6 +542,7 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
|
|||
config := AIConfig{
|
||||
OllamaBaseURL: "http://custom:11434",
|
||||
OpenAIBaseURL: "https://custom-openai.com",
|
||||
ZaiBaseURL: "https://api.z.ai/api/coding/paas/v4",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
|
|
@ -553,7 +554,7 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
|
|||
{AIProviderOpenRouter, DefaultOpenRouterBaseURL},
|
||||
{AIProviderDeepSeek, DefaultDeepSeekBaseURL},
|
||||
{AIProviderGemini, DefaultGeminiBaseURL},
|
||||
{AIProviderZai, DefaultZaiBaseURL},
|
||||
{AIProviderZai, "https://api.z.ai/api/coding/paas/v4"},
|
||||
{AIProviderGroq, DefaultGroqBaseURL},
|
||||
{AIProviderMistral, DefaultMistralBaseURL},
|
||||
{AIProviderCerebras, DefaultCerebrasBaseURL},
|
||||
|
|
@ -580,6 +581,9 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
|
|||
if url := cfg.GetBaseURLForProvider(AIProviderOpenAI); url != "" {
|
||||
t.Errorf("openai default = %q, want empty", url)
|
||||
}
|
||||
if url := cfg.GetBaseURLForProvider(AIProviderZai); url != DefaultZaiBaseURL {
|
||||
t.Errorf("zai default = %q, want %q", url, DefaultZaiBaseURL)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ func aiProviderDefinitions() []AIProviderDefinition {
|
|||
APIKeyField: "zai_api_key",
|
||||
ConfiguredField: "zai_configured",
|
||||
ClearKeyField: "clear_zai_key",
|
||||
BaseURLField: "zai_base_url",
|
||||
RequiresAPIKey: true,
|
||||
UserConfigurable: true,
|
||||
ModelsDevProviderID: "zai",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue