From 1f6fe16e497d98765887c053a56620afc94ba799 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 23 Jun 2026 22:49:55 +0100 Subject: [PATCH] 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. --- .../v6/internal/subsystems/agent-lifecycle.md | 5 ++++- .../v6/internal/subsystems/ai-runtime.md | 4 ++++ .../v6/internal/subsystems/api-contracts.md | 2 +- .../v6/internal/subsystems/frontend-primitives.md | 3 ++- .../v6/internal/subsystems/storage-recovery.md | 5 ++++- frontend-modern/src/api/__tests__/ai.test.ts | 4 ++-- .../Settings/__tests__/settingsArchitecture.test.ts | 1 + .../src/components/Settings/aiSettingsModel.ts | 10 ++++++++++ .../src/components/Settings/useAISettingsState.ts | 8 +++++++- frontend-modern/src/types/ai.ts | 2 ++ internal/api/ai_handlers.go | 9 +++++++++ internal/api/ai_handlers_test.go | 4 ++++ internal/config/ai.go | 7 +++++-- internal/config/ai_config_test.go | 6 +++++- internal/config/ai_providers.go | 1 + 15 files changed, 61 insertions(+), 10 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 512916530..cd04611db 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c3da62dfc..e86cc27e6 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index be88ef6da..c32f880b6 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 7de1fb59d..5e2b59a5b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 6f974351f..236c5b652 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/src/api/__tests__/ai.test.ts b/frontend-modern/src/api/__tests__/ai.test.ts index 47def5638..3d278202a 100644 --- a/frontend-modern/src/api/__tests__/ai.test.ts +++ b/frontend-modern/src/api/__tests__/ai.test.ts @@ -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); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 829925dd9..498f0c1ff 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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(''); expect(aiProviderConfigurationSectionSource).toContain( 'aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField.label}`}', diff --git a/frontend-modern/src/components/Settings/aiSettingsModel.ts b/frontend-modern/src/components/Settings/aiSettingsModel.ts index 1ccc3d5a5..5ef2ba855 100644 --- a/frontend-modern/src/components/Settings/aiSettingsModel.ts +++ b/frontend-modern/src/components/Settings/aiSettingsModel.ts @@ -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', }, { diff --git a/frontend-modern/src/components/Settings/useAISettingsState.ts b/frontend-modern/src/components/Settings/useAISettingsState.ts index f359a3004..9e8dde95f 100644 --- a/frontend-modern/src/components/Settings/useAISettingsState.ts +++ b/frontend-modern/src/components/Settings/useAISettingsState.ts @@ -54,7 +54,7 @@ const AI_SETTINGS_PROVIDER_PAYLOAD_FIELDS: Record = { 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); diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index 9d2dd2ee5..bf33d3fb3 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -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 diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 5ec271343..8b3d3691b 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -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, diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 6a857a017..6b4874e57 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -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) } diff --git a/internal/config/ai.go b/internal/config/ai.go index a1184ee83..24eb0e9bc 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -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 diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go index e20cd1043..fa6c4770a 100644 --- a/internal/config/ai_config_test.go +++ b/internal/config/ai_config_test.go @@ -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) + } }) } diff --git a/internal/config/ai_providers.go b/internal/config/ai_providers.go index d9981e210..d1f007ed8 100644 --- a/internal/config/ai_providers.go +++ b/internal/config/ai_providers.go @@ -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",